mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: preserve unread reminder for open sessions (#129386)
* fix: preserve manual unread markers in open sessions * fix: distinguish explicit session reads * fix(ui): gate unread contract on gateway capability * perf(ui): keep server capabilities out of startup bundle * test(gateway): keep agent fixtures roster-consistent * fix(sessions): preserve legacy read compatibility * test(gateway): type agent fixture configs * fix(ui): remove unread gateway fallback Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * test(infra): avoid fixed SSH tunnel port * fix(ui): acknowledge unread after history commit * docs: clarify unread upgrade boundary * test(ui): drive mobile session menu by tap * fix(ios): remove stale read reconciliation call --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import ai.openclaw.app.chat.MessageSpeechClient
|
||||
import ai.openclaw.app.chat.MessageSpeechController
|
||||
import ai.openclaw.app.chat.MessageSpeechState
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.chat.SESSION_UNREAD_ACK_CAPABILITY
|
||||
import ai.openclaw.app.chat.SessionBranch
|
||||
import ai.openclaw.app.chat.SessionForkResult
|
||||
import ai.openclaw.app.chat.SessionRewindResult
|
||||
@@ -1330,6 +1331,7 @@ class NodeRuntime private constructor(
|
||||
private val gatewayMethodsLock = Any()
|
||||
private var gatewayApprovalRpcFamily = GatewayApprovalRpcFamily.Unavailable
|
||||
private var gatewayAdvertisedMethods: Set<String>? = null
|
||||
private var gatewayAdvertisedCapabilities: Set<String>? = null
|
||||
private var gatewayMethodsEpoch = 0L
|
||||
|
||||
@Volatile internal var gatewayDataRequestOverrideForTests: GatewayDataRequestOverride? = null
|
||||
@@ -1408,6 +1410,7 @@ class NodeRuntime private constructor(
|
||||
_gatewayVersion.value = hello.serverVersion
|
||||
_gatewayUpdateAvailable.value = hello.updateAvailable
|
||||
replaceGatewayMethods(hello.methods)
|
||||
replaceGatewayCapabilities(hello.capabilities)
|
||||
val operatorScopes = normalizeOperatorScopes(hello.authScopes)
|
||||
_operatorScopes.value = operatorScopes
|
||||
// Pairing capabilities require positive hello advertisement; an unknown catalog grants none.
|
||||
@@ -1681,6 +1684,7 @@ class NodeRuntime private constructor(
|
||||
_gatewayVersion.value = null
|
||||
_gatewayUpdateAvailable.value = null
|
||||
replaceGatewayMethods(null)
|
||||
replaceGatewayCapabilities(null)
|
||||
_operatorScopes.value = emptyList()
|
||||
_devicePairingCapabilities.value = GatewayDevicePairingCapabilities()
|
||||
_gatewayAccentArgb.value = null
|
||||
@@ -1929,6 +1933,7 @@ class NodeRuntime private constructor(
|
||||
currentDefaultAgentId = { gatewayDefaultAgentId.value },
|
||||
currentDefaultAgentRevision = gatewayDefaultAgentRevision::get,
|
||||
gatewayAdvertisesMethod = ::gatewayAdvertisesMethod,
|
||||
gatewayAdvertisesCapability = ::gatewayAdvertisesCapability,
|
||||
commandOutbox = chatCommandOutbox,
|
||||
recordModelRecent = prefs::recordModelRecent,
|
||||
onSessionDeleted = ::publishChatSessionDeletion,
|
||||
@@ -1945,6 +1950,7 @@ class NodeRuntime private constructor(
|
||||
json = json,
|
||||
requestGateway = AndroidScreenshotFixture::request,
|
||||
gatewayAdvertisesMethod = { _ -> true },
|
||||
gatewayAdvertisesCapability = { _ -> true },
|
||||
)
|
||||
}.also {
|
||||
it.applyMainSessionKey(_mainSessionKey.value)
|
||||
@@ -2906,6 +2912,7 @@ class NodeRuntime private constructor(
|
||||
_remoteAddress.value = "Mac Studio on local network"
|
||||
_gatewayVersion.value = BuildConfig.VERSION_NAME
|
||||
replaceGatewayMethods(setOf(GatewayMethod.DesktopObserve.rawValue))
|
||||
replaceGatewayCapabilities(setOf(SESSION_UNREAD_ACK_CAPABILITY))
|
||||
_gatewayControlPage.value =
|
||||
GatewayControlPage(
|
||||
baseUrl = AndroidScreenshotFixture.controlUiBaseUrl,
|
||||
@@ -7484,6 +7491,14 @@ class NodeRuntime private constructor(
|
||||
|
||||
private fun gatewayAdvertisesMethod(method: String): Boolean? = synchronized(gatewayMethodsLock) { gatewayAdvertisedMethods?.let { method in it } }
|
||||
|
||||
private fun replaceGatewayCapabilities(capabilities: Set<String>?) {
|
||||
synchronized(gatewayMethodsLock) {
|
||||
gatewayAdvertisedCapabilities = capabilities
|
||||
}
|
||||
}
|
||||
|
||||
private fun gatewayAdvertisesCapability(capability: String): Boolean? = synchronized(gatewayMethodsLock) { gatewayAdvertisedCapabilities?.let { capability in it } }
|
||||
|
||||
private fun captureGatewayMethods(): GatewayMethodsSnapshot =
|
||||
synchronized(gatewayMethodsLock) {
|
||||
GatewayMethodsSnapshot(
|
||||
|
||||
@@ -57,6 +57,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// Bounds one-shot search list fetches like the primary session list.
|
||||
internal const val SESSION_LIST_FETCH_LIMIT = 200
|
||||
internal const val SESSION_UNREAD_ACK_CAPABILITY = "session-unread-ack-contract"
|
||||
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
|
||||
@@ -113,6 +114,7 @@ class ChatController internal constructor(
|
||||
private val requestGatewayForGateway: suspend (gatewayId: String, method: String, paramsJson: String?) -> String =
|
||||
{ _, method, paramsJson -> requestGateway(method, paramsJson) },
|
||||
private val gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
|
||||
private val gatewayAdvertisesCapability: (capability: String) -> Boolean? = { null },
|
||||
private val captureSettingsRequestLease: (gatewayScope: ChatCacheScope?) -> GatewaySession.RequestLease? =
|
||||
{ gatewayScope ->
|
||||
GatewaySession.RequestLease(endpointStableId = gatewayScope?.gatewayId.orEmpty()) { method, paramsJson, _ ->
|
||||
@@ -156,6 +158,7 @@ class ChatController internal constructor(
|
||||
currentDefaultAgentId: () -> String? = { "main" },
|
||||
currentDefaultAgentRevision: () -> Long = { 0L },
|
||||
gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
|
||||
gatewayAdvertisesCapability: (capability: String) -> Boolean? = { null },
|
||||
commandOutbox: ChatCommandOutbox? = null,
|
||||
recordModelRecent: (String) -> Unit = {},
|
||||
onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
|
||||
@@ -172,6 +175,7 @@ class ChatController internal constructor(
|
||||
session.requestForEndpoint(gatewayId, method, paramsJson)
|
||||
},
|
||||
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
|
||||
gatewayAdvertisesCapability = gatewayAdvertisesCapability,
|
||||
captureSettingsRequestLease = { gatewayScope ->
|
||||
session.captureRequestLease(gatewayScope?.gatewayId)
|
||||
},
|
||||
@@ -619,6 +623,8 @@ class ChatController internal constructor(
|
||||
// server-confirmed read (unread=false) arrives, so fresh activity on the open
|
||||
// session re-acknowledges without patch loops (lastReadAt is stamped server-side).
|
||||
private var unreadPatchSessionKey: String? = null
|
||||
private var unreadActivationObserved = false
|
||||
private var unreadActivationMarkedUnreadAt: Long? = null
|
||||
private var unreadPatchRequested = false
|
||||
|
||||
// Armed on disconnect so the next health event refetches history and re-adopts
|
||||
@@ -833,6 +839,8 @@ class ChatController internal constructor(
|
||||
applyThinkingMetadata(null)
|
||||
sessionsListArchived = false
|
||||
unreadPatchSessionKey = null
|
||||
unreadActivationObserved = false
|
||||
unreadActivationMarkedUnreadAt = null
|
||||
unreadPatchRequested = false
|
||||
_commands.value = emptyList()
|
||||
_modelCatalog.value = emptyList()
|
||||
@@ -1050,6 +1058,7 @@ class ChatController internal constructor(
|
||||
pinned: Boolean? = null,
|
||||
archived: Boolean? = null,
|
||||
unread: Boolean? = null,
|
||||
unreadExpectation: ChatSessionUnreadExpectation? = null,
|
||||
): Boolean {
|
||||
val sessionKey = key.trim().takeIf { it.isNotEmpty() } ?: return false
|
||||
val capturedOwnerAgentId =
|
||||
@@ -1082,6 +1091,10 @@ class ChatController internal constructor(
|
||||
if (pinned != null) put("pinned", JsonPrimitive(pinned))
|
||||
if (archived != null) put("archived", JsonPrimitive(archived))
|
||||
if (unread != null) put("unread", JsonPrimitive(unread))
|
||||
if (unreadExpectation != null) {
|
||||
val marker = unreadExpectation.markedUnreadAt
|
||||
put("expectedMarkedUnreadAt", marker?.let(::JsonPrimitive) ?: JsonNull)
|
||||
}
|
||||
}
|
||||
if (archived == true) {
|
||||
requestGatewayWithTimeout("sessions.patch", params.toString(), 10 * 60_000L)
|
||||
@@ -2348,6 +2361,8 @@ class ChatController internal constructor(
|
||||
private fun prepareSessionSelection(key: String) {
|
||||
if (key != unreadPatchSessionKey) {
|
||||
unreadPatchSessionKey = key
|
||||
unreadActivationObserved = false
|
||||
unreadActivationMarkedUnreadAt = null
|
||||
unreadPatchRequested = false
|
||||
}
|
||||
acknowledgeUnreadIfNeeded(key, _sessions.value.firstOrNull { it.key == key })
|
||||
@@ -6655,6 +6670,8 @@ class ChatController internal constructor(
|
||||
archived = obj["archived"].asBooleanOrNull(),
|
||||
unread = obj["unread"].asBooleanOrNull(),
|
||||
lastReadAt = obj["lastReadAt"].asLongOrNull(),
|
||||
markedUnreadAt = obj["markedUnreadAt"].asLongOrNull(),
|
||||
hasMarkedUnreadMetadata = "markedUnreadAt" in obj,
|
||||
agentStatus = parseSessionAgentStatus(obj["agentStatus"]),
|
||||
hasAgentStatusMetadata = "agentStatus" in obj,
|
||||
observerDigest =
|
||||
@@ -6958,19 +6975,44 @@ class ChatController internal constructor(
|
||||
requireActive: Boolean = false,
|
||||
) {
|
||||
if (key.isEmpty() || key != unreadPatchSessionKey) return
|
||||
if (entry?.unread == false) {
|
||||
if (entry == null) return
|
||||
if (!unreadActivationObserved) {
|
||||
unreadActivationObserved = true
|
||||
unreadActivationMarkedUnreadAt = entry.markedUnreadAt
|
||||
}
|
||||
if (entry.unread == false) {
|
||||
unreadActivationMarkedUnreadAt = null
|
||||
unreadPatchRequested = false
|
||||
return
|
||||
}
|
||||
if (entry?.unread != true || unreadPatchRequested) return
|
||||
val markedUnreadAt = entry.markedUnreadAt
|
||||
if (markedUnreadAt != null && markedUnreadAt != unreadActivationMarkedUnreadAt) {
|
||||
return
|
||||
}
|
||||
if (entry.unread != true || unreadPatchRequested) return
|
||||
// switchSession acknowledges before _sessionKey updates; background upserts only
|
||||
// re-acknowledge the session that is currently open.
|
||||
if (requireActive && key != _sessionKey.value) return
|
||||
unreadPatchRequested = true
|
||||
_sessions.value = _sessions.value.map { if (it.key == key) it.copy(unread = false) else it }
|
||||
// Native app and Gateway releases can skew. Only current Gateways accept
|
||||
// the closed-schema conditional acknowledgement field.
|
||||
val unreadExpectation =
|
||||
if (gatewayAdvertisesCapability(SESSION_UNREAD_ACK_CAPABILITY) == true) {
|
||||
ChatSessionUnreadExpectation(entry.markedUnreadAt)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
scope.launch {
|
||||
// A failed read patch must unlatch the episode so later snapshots retry.
|
||||
if (!patchSession(key = key, ownerAgentId = entry.ownerAgentId, unread = false) && unreadPatchSessionKey == key) {
|
||||
if (
|
||||
!patchSession(
|
||||
key = key,
|
||||
ownerAgentId = entry.ownerAgentId,
|
||||
unread = false,
|
||||
unreadExpectation = unreadExpectation,
|
||||
) &&
|
||||
unreadPatchSessionKey == key
|
||||
) {
|
||||
unreadPatchRequested = false
|
||||
}
|
||||
}
|
||||
@@ -7594,6 +7636,10 @@ internal fun mergeChatSessionEntry(
|
||||
archived = next.archived ?: existing.archived,
|
||||
unread = next.unread ?: existing.unread,
|
||||
lastReadAt = next.lastReadAt ?: existing.lastReadAt,
|
||||
markedUnreadAt =
|
||||
if (next.hasMarkedUnreadMetadata) next.markedUnreadAt else existing.markedUnreadAt,
|
||||
hasMarkedUnreadMetadata =
|
||||
existing.hasMarkedUnreadMetadata || next.hasMarkedUnreadMetadata,
|
||||
agentStatus = if (next.hasAgentStatusMetadata) next.agentStatus else existing.agentStatus,
|
||||
hasAgentStatusMetadata = existing.hasAgentStatusMetadata || next.hasAgentStatusMetadata,
|
||||
observerDigest = observerDigest,
|
||||
|
||||
@@ -316,6 +316,8 @@ data class ChatSessionEntry(
|
||||
val archived: Boolean? = null,
|
||||
val unread: Boolean? = null,
|
||||
val lastReadAt: Long? = null,
|
||||
val markedUnreadAt: Long? = null,
|
||||
val hasMarkedUnreadMetadata: Boolean = markedUnreadAt != null,
|
||||
val agentStatus: ChatSessionAgentStatus? = null,
|
||||
val hasAgentStatusMetadata: Boolean = agentStatus != null,
|
||||
val observerDigest: SessionObserverDigest? = null,
|
||||
@@ -352,6 +354,10 @@ data class ChatSessionEntry(
|
||||
status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null,
|
||||
)
|
||||
|
||||
data class ChatSessionUnreadExpectation(
|
||||
val markedUnreadAt: Long?,
|
||||
)
|
||||
|
||||
data class ChatSessionAgentStatus(
|
||||
val note: String,
|
||||
val expiresAt: Long,
|
||||
|
||||
@@ -215,6 +215,7 @@ data class GatewayHelloSummary(
|
||||
val authRole: String? = null,
|
||||
val authScopes: List<String> = emptyList(),
|
||||
val methods: Set<String>? = null,
|
||||
val capabilities: Set<String>? = null,
|
||||
)
|
||||
|
||||
data class GatewayUpdateAvailableSummary(
|
||||
@@ -1442,6 +1443,13 @@ class GatewaySession(
|
||||
.asArrayOrNull()
|
||||
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { method -> method.isNotEmpty() } }
|
||||
?.toSet()
|
||||
val capabilities =
|
||||
obj["features"]
|
||||
.asObjectOrNull()
|
||||
?.get("capabilities")
|
||||
.asArrayOrNull()
|
||||
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { capability -> capability.isNotEmpty() } }
|
||||
?.toSet()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
@@ -1506,6 +1514,7 @@ class GatewaySession(
|
||||
authRole = authRole,
|
||||
authScopes = authScopes,
|
||||
methods = methods,
|
||||
capabilities = capabilities,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+73
@@ -553,6 +553,23 @@ class ChatControllerCommandControlsTest {
|
||||
assertEquals(2, requests.count { it.first == "sessions.patch" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitMarkReadUsesLegacyCompatiblePayloadOnCurrentGateway() =
|
||||
runTest {
|
||||
val (controller, requests) =
|
||||
chatControllerTestSetup {
|
||||
gatewayAdvertisesCapability = { it == SESSION_UNREAD_ACK_CAPABILITY }
|
||||
}
|
||||
|
||||
assertTrue(controller.patchSession(key = "main", unread = false))
|
||||
advanceUntilIdle()
|
||||
|
||||
val patch = requests.single { it.first == "sessions.patch" }.second.orEmpty()
|
||||
assertTrue(patch.contains("\"unread\":false"))
|
||||
assertFalse(patch.contains("readIntent"))
|
||||
assertFalse(patch.contains("expectedMarkedUnreadAt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivingOrDeletingTheOpenSessionFallsBackToMain() =
|
||||
runTest {
|
||||
@@ -602,6 +619,13 @@ class ChatControllerCommandControlsTest {
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, requests.count { it.first == "sessions.patch" })
|
||||
assertFalse(
|
||||
requests
|
||||
.single { it.first == "sessions.patch" }
|
||||
.second
|
||||
.orEmpty()
|
||||
.contains("expectedMarkedUnreadAt"),
|
||||
)
|
||||
|
||||
// Server-confirmed read resets the episode; a stale duplicate must not re-patch.
|
||||
controller.handleGatewayEvent(
|
||||
@@ -617,6 +641,55 @@ class ChatControllerCommandControlsTest {
|
||||
assertEquals(2, requests.count { it.first == "sessions.patch" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualUnreadOnOpenSessionSurvivesRunUpdatesUntilReactivation() =
|
||||
runTest {
|
||||
val (controller, requests) =
|
||||
chatControllerTestSetup {
|
||||
gatewayAdvertisesCapability = { it == SESSION_UNREAD_ACK_CAPABILITY }
|
||||
respond(
|
||||
"sessions.list",
|
||||
"""{"sessions":[{"key":"main","unread":false},{"key":"other","unread":false}]}""",
|
||||
)
|
||||
}
|
||||
|
||||
controller.refreshSessions()
|
||||
advanceUntilIdle()
|
||||
controller.switchSession("main")
|
||||
advanceUntilIdle()
|
||||
|
||||
controller.handleGatewayEvent(
|
||||
"sessions.changed",
|
||||
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true,"markedUnreadAt":100}}""",
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, requests.count { it.first == "sessions.patch" })
|
||||
val retained = controller.sessions.value.first { it.key == "main" }
|
||||
assertEquals(true, retained.unread)
|
||||
assertEquals(100L, retained.markedUnreadAt)
|
||||
|
||||
controller.handleGatewayEvent(
|
||||
"sessions.changed",
|
||||
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true,"markedUnreadAt":100,"hasActiveRun":true,"status":"running"}}""",
|
||||
)
|
||||
controller.handleGatewayEvent(
|
||||
"sessions.changed",
|
||||
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true,"markedUnreadAt":100,"hasActiveRun":false,"status":"done"}}""",
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, requests.count { it.first == "sessions.patch" })
|
||||
|
||||
controller.switchSession("other")
|
||||
advanceUntilIdle()
|
||||
controller.switchSession("main")
|
||||
advanceUntilIdle()
|
||||
|
||||
val patch = requests.single { it.first == "sessions.patch" }.second.orEmpty()
|
||||
assertTrue(patch.contains("\"unread\":false"))
|
||||
assertTrue(patch.contains("\"expectedMarkedUnreadAt\":100"))
|
||||
assertFalse(patch.contains("readIntent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startNewChatWithoutLoadedParentCreatesFirstSession() =
|
||||
runTest {
|
||||
|
||||
@@ -181,6 +181,7 @@ class ChatControllerSessionPolicyTest {
|
||||
archived = false,
|
||||
unread = true,
|
||||
lastReadAt = 10L,
|
||||
markedUnreadAt = 15L,
|
||||
lastActivityAt = 20L,
|
||||
)
|
||||
val next = ChatSessionEntry(key = "agent:main:phone", updatedAtMs = 2L)
|
||||
@@ -193,6 +194,7 @@ class ChatControllerSessionPolicyTest {
|
||||
assertEquals(false, merged.archived)
|
||||
assertEquals(true, merged.unread)
|
||||
assertEquals(10L, merged.lastReadAt)
|
||||
assertEquals(15L, merged.markedUnreadAt)
|
||||
assertEquals(20L, merged.lastActivityAt)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -837,6 +837,7 @@ class ChatControllerTranscriptCacheTest {
|
||||
"archived": false,
|
||||
"unread": true,
|
||||
"lastReadAt": 10,
|
||||
"markedUnreadAt": 15,
|
||||
"lastActivityAt": 20
|
||||
}]
|
||||
}
|
||||
@@ -854,6 +855,7 @@ class ChatControllerTranscriptCacheTest {
|
||||
assertEquals(false, session.archived)
|
||||
assertEquals(true, session.unread)
|
||||
assertEquals(10L, session.lastReadAt)
|
||||
assertEquals(15L, session.markedUnreadAt)
|
||||
assertEquals(20L, session.lastActivityAt)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ internal fun CoroutineScope.createChatController(
|
||||
currentDefaultAgentId: () -> String? = { "main" },
|
||||
currentDefaultAgentRevision: () -> Long = { 0L },
|
||||
gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
|
||||
gatewayAdvertisesCapability: (capability: String) -> Boolean? = { null },
|
||||
recordModelRecent: (String) -> Unit = {},
|
||||
onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
|
||||
onOfflineDefaultAgentRestored: (String) -> Unit = {},
|
||||
@@ -49,6 +50,7 @@ internal fun CoroutineScope.createChatController(
|
||||
currentDefaultAgentId = currentDefaultAgentId,
|
||||
currentDefaultAgentRevision = currentDefaultAgentRevision,
|
||||
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
|
||||
gatewayAdvertisesCapability = gatewayAdvertisesCapability,
|
||||
recordModelRecent = recordModelRecent,
|
||||
onSessionDeleted = onSessionDeleted,
|
||||
onOfflineDefaultAgentRestored = onOfflineDefaultAgentRestored,
|
||||
@@ -62,6 +64,7 @@ internal class ChatControllerTestSetup(
|
||||
val requests = mutableListOf<Pair<String, String?>>()
|
||||
var cacheScope: () -> ChatCacheScope? = { null }
|
||||
var gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }
|
||||
var gatewayAdvertisesCapability: (capability: String) -> Boolean? = { null }
|
||||
var recordModelRecent: (String) -> Unit = {}
|
||||
|
||||
private val handlers = mutableMapOf<String, suspend (String?) -> String>()
|
||||
@@ -84,6 +87,7 @@ internal class ChatControllerTestSetup(
|
||||
scope.createChatController(
|
||||
cacheScope = cacheScope,
|
||||
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
|
||||
gatewayAdvertisesCapability = gatewayAdvertisesCapability,
|
||||
recordModelRecent = recordModelRecent,
|
||||
requestGateway = { method, paramsJson ->
|
||||
requests += method to paramsJson
|
||||
|
||||
+25
-1
@@ -424,6 +424,28 @@ class GatewaySessionReconnectTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedHelloPublishesServerCapabilities() =
|
||||
runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val hello = CompletableDeferred<GatewayHelloSummary>()
|
||||
val capabilities = setOf("session-unread-ack-contract")
|
||||
val server =
|
||||
startGatewayServer(json = json) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
webSocket.send(connectResponseFrame(id, capabilities = capabilities))
|
||||
}
|
||||
}
|
||||
val harness = createReconnectHarness(onHello = hello::complete)
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
assertEquals(capabilities, withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { hello.await() }.capabilities)
|
||||
} finally {
|
||||
shutdownReconnectHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedHelloKeepsMethodCatalogUnknownWhenHelloOmitsFeatures() =
|
||||
runBlocking {
|
||||
@@ -1306,12 +1328,14 @@ class GatewaySessionReconnectTest {
|
||||
private fun connectResponseFrame(
|
||||
id: String,
|
||||
methods: Set<String>? = emptySet(),
|
||||
capabilities: Set<String> = emptySet(),
|
||||
): String {
|
||||
if (methods == null) {
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
}
|
||||
val encodedMethods = methods.joinToString(",") { JsonPrimitive(it).toString() }
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{"features":{"methods":[$encodedMethods]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
val encodedCapabilities = capabilities.joinToString(",") { JsonPrimitive(it).toString() }
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{"features":{"methods":[$encodedMethods],"capabilities":[$encodedCapabilities]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
}
|
||||
|
||||
private fun startGatewayServer(
|
||||
|
||||
@@ -100,9 +100,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
|
||||
func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease? {
|
||||
guard let route = await currentSessionMutationRoute() else { return nil }
|
||||
let unreadAckContract = await self.gateway.supportsServerCapability(
|
||||
.sessionUnreadAckContract,
|
||||
ifCurrentRoute: route)
|
||||
let transport = self
|
||||
return OpenClawChatSessionMutationRouteLease(
|
||||
patchSession: { key, expectedSessionID, label, category, pinned, archived, unread in
|
||||
patchSession: { key, expectedSessionID, expectedMarkedUnreadAt, label, category, pinned, archived, unread in
|
||||
guard unread != false || unreadAckContract != nil else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
let target = transport.sessionTarget(for: key)
|
||||
let request = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: target.sessionKey,
|
||||
@@ -112,7 +118,10 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
category: category,
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
unreadPatch: .routed(
|
||||
unread: unread,
|
||||
expectedMarkedUnreadAt: expectedMarkedUnreadAt,
|
||||
supportsReadContract: unreadAckContract == true))
|
||||
_ = try await transport.requestSessionMutation(request, ifCurrentRoute: route)
|
||||
},
|
||||
deleteSession: { key in
|
||||
@@ -439,6 +448,20 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
archived: Bool? = nil,
|
||||
unread: Bool? = nil) async throws
|
||||
{
|
||||
if let routeLease = await self.acquireSessionMutationRouteLease() {
|
||||
try await routeLease.patchSession(
|
||||
key: key,
|
||||
expectedSessionID: expectedSessionID,
|
||||
label: label,
|
||||
category: category,
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
return
|
||||
}
|
||||
guard self.sessionMutationRequest != nil else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
let target = self.sessionTarget(for: key)
|
||||
let request = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: target.sessionKey,
|
||||
@@ -448,7 +471,10 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
category: category,
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
unreadPatch: .routed(
|
||||
unread: unread,
|
||||
expectedMarkedUnreadAt: nil,
|
||||
supportsReadContract: false))
|
||||
_ = try await self.requestSessionMutation(request)
|
||||
}
|
||||
|
||||
|
||||
@@ -614,10 +614,10 @@ struct CommandCenterTab: View {
|
||||
self.appModel.chatViewModelIdentityID
|
||||
}
|
||||
|
||||
private func open(_ route: WorkRoute, unread: Bool = false) {
|
||||
private func open(_ route: WorkRoute) {
|
||||
switch route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey, unread: unread)
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
@@ -625,11 +625,11 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
private func open(_ session: OpenClawChatSessionEntry) {
|
||||
self.open(.chat(session.key), unread: session.unread == true)
|
||||
self.open(.chat(session.key))
|
||||
}
|
||||
|
||||
private func openDefaultChatSession() {
|
||||
self.open(.chat(nil), unread: self.effectiveDefaultChatSessionEntry?.unread == true)
|
||||
self.open(.chat(nil))
|
||||
}
|
||||
|
||||
private func patchSession(
|
||||
@@ -1302,11 +1302,11 @@ struct CommandSessionsScreen: View {
|
||||
}
|
||||
|
||||
private func open(_ session: OpenClawChatSessionEntry) {
|
||||
self.openSessionKey(session.key, unread: session.unread == true)
|
||||
self.openSessionKey(session.key)
|
||||
}
|
||||
|
||||
private func openSessionKey(_ key: String, unread: Bool = false) {
|
||||
self.appModel.openChat(sessionKey: key, unread: unread)
|
||||
private func openSessionKey(_ key: String) {
|
||||
self.appModel.openChat(sessionKey: key)
|
||||
self.dismiss()
|
||||
self.openChat()
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ struct IPadActivityScreen: View {
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey, unread: item.isUnread)
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
|
||||
@@ -407,11 +407,6 @@ final class NodeAppModel {
|
||||
private var gatewaySessionScope: String?
|
||||
var gatewayAccentColorHex: String?
|
||||
private var focusedChatSessionKey: String?
|
||||
// Two-part unread guard mirroring Android: the opened key survives read
|
||||
// confirmations so later unread episodes on the same open chat re-acknowledge;
|
||||
// the acknowledged key is the per-episode pending flag.
|
||||
@ObservationIgnored private var openedChatSessionKey: String?
|
||||
@ObservationIgnored private var readAcknowledgedChatSessionKey: String?
|
||||
var selectedAgentId: String?
|
||||
var gatewayDefaultAgentId: String?
|
||||
var gatewayAgents: [AgentSummary] = []
|
||||
@@ -3367,16 +3362,8 @@ extension NodeAppModel {
|
||||
self.mainSessionKey
|
||||
}
|
||||
|
||||
func openChat(sessionKey: String?, unread: Bool = false) {
|
||||
func openChat(sessionKey: String?) {
|
||||
self.focusChatSession(sessionKey)
|
||||
let activeKey = self.chatSessionKey
|
||||
self.openedChatSessionKey = activeKey
|
||||
if self.readAcknowledgedChatSessionKey != activeKey {
|
||||
self.readAcknowledgedChatSessionKey = nil
|
||||
}
|
||||
if unread {
|
||||
self.acknowledgeChatSessionReadIfNeeded(activeKey)
|
||||
}
|
||||
self.openChatRequestID &+= 1
|
||||
}
|
||||
|
||||
@@ -3402,47 +3389,6 @@ extension NodeAppModel {
|
||||
return true
|
||||
}
|
||||
|
||||
/// One acknowledgement per unread episode: the pending flag clears when a fresh
|
||||
/// snapshot confirms the read (unread != true), so a run finishing while the
|
||||
/// session stays open re-acknowledges without patch loops (the gateway stamps
|
||||
/// lastReadAt server-side, which makes the exchange convergent).
|
||||
func reconcileChatSessionReadState(_ entries: [OpenClawChatSessionEntry]) {
|
||||
guard let openedKey = self.openedChatSessionKey,
|
||||
let entry = entries.first(where: { $0.key == openedKey })
|
||||
else { return }
|
||||
if entry.unread != true {
|
||||
if self.readAcknowledgedChatSessionKey == openedKey {
|
||||
self.readAcknowledgedChatSessionKey = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
// Only the currently open chat auto-acknowledges fresh unread episodes.
|
||||
guard openedKey == self.chatSessionKey else { return }
|
||||
self.acknowledgeChatSessionReadIfNeeded(openedKey)
|
||||
}
|
||||
|
||||
private func acknowledgeChatSessionReadIfNeeded(_ sessionKey: String) {
|
||||
guard self.readAcknowledgedChatSessionKey != sessionKey else { return }
|
||||
self.readAcknowledgedChatSessionKey = sessionKey
|
||||
let transport = self.makeChatTransport()
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await transport.patchSession(
|
||||
key: sessionKey,
|
||||
expectedSessionID: nil,
|
||||
label: nil,
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
archived: nil,
|
||||
unread: false)
|
||||
} catch {
|
||||
if self.readAcknowledgedChatSessionKey == sessionKey {
|
||||
self.readAcknowledgedChatSessionKey = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func focusChatSession(_ sessionKey: String?) {
|
||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.focusedChatSessionKey = trimmed.isEmpty ? nil : trimmed
|
||||
|
||||
@@ -463,7 +463,7 @@ struct RootSidebar: View {
|
||||
self.resolvedSelectedSessionKey.caseInsensitiveCompare(mainKey) == .orderedSame
|
||||
let mainSession = self.mainSessionEntry
|
||||
return Button {
|
||||
self.appModel.openChat(sessionKey: mainKey, unread: mainSession?.unread == true)
|
||||
self.appModel.openChat(sessionKey: mainKey)
|
||||
self.selectSidebarDestination(.chat)
|
||||
} label: {
|
||||
HStack(spacing: 9) {
|
||||
@@ -600,7 +600,7 @@ struct RootSidebar: View {
|
||||
let session = node.session
|
||||
let isSelected = session.key == selectedSessionKey
|
||||
return Button {
|
||||
self.appModel.openChat(sessionKey: session.key, unread: session.unread == true)
|
||||
self.appModel.openChat(sessionKey: session.key)
|
||||
self.selectSidebarDestination(.chat)
|
||||
} label: {
|
||||
HStack(spacing: 9) {
|
||||
|
||||
@@ -134,7 +134,6 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
if !archived {
|
||||
self.reconcileChatSessionReadState(snapshot.sessions)
|
||||
// An interrupted page must not replace a more complete offline roster.
|
||||
if snapshot.isComplete {
|
||||
await self.storeCachedChatSessions(snapshot.sessions)
|
||||
|
||||
@@ -13,6 +13,7 @@ struct ChatSessionsCodingTests {
|
||||
"archived":false,
|
||||
"unread":true,
|
||||
"lastReadAt":1720000000000,
|
||||
"markedUnreadAt":1720000002500,
|
||||
"lastActivityAt":1720000005000
|
||||
}
|
||||
"""#.utf8)
|
||||
@@ -25,6 +26,7 @@ struct ChatSessionsCodingTests {
|
||||
#expect(entry.archived == false)
|
||||
#expect(entry.unread == true)
|
||||
#expect(entry.lastReadAt == 1_720_000_000_000)
|
||||
#expect(entry.markedUnreadAt == 1_720_000_002_500)
|
||||
#expect(entry.lastActivityAt == 1_720_000_005_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ struct IOSGatewayChatTransportTests {
|
||||
"type":"hello-ok",
|
||||
"protocol":4,
|
||||
"server":{"version":"test","connId":"test"},
|
||||
"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract"]},
|
||||
"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract","session-unread-ack-contract"]},
|
||||
"snapshot":{
|
||||
"presence":[],
|
||||
"health":{},
|
||||
@@ -92,6 +92,7 @@ struct IOSGatewayChatTransportTests {
|
||||
"""#.utf8)
|
||||
let hello = try JSONDecoder().decode(HelloOk.self, from: data)
|
||||
#expect(hello.supportsServerCapability(.chatSendRoutingContract))
|
||||
#expect(hello.supportsServerCapability(.sessionUnreadAckContract))
|
||||
}
|
||||
|
||||
@Test func `session mutations dispatch normalized selected agent targets`() async throws {
|
||||
|
||||
@@ -74,9 +74,15 @@ extension MacGatewayChatTransport {
|
||||
func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease? {
|
||||
guard let serverLease = await self.connection.captureServerLease() else { return nil }
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else { return nil }
|
||||
let unreadAckContract = await self.connection.supportsServerCapability(
|
||||
.sessionUnreadAckContract,
|
||||
ifCurrentServerLease: serverLease)
|
||||
let transport = self
|
||||
return OpenClawChatSessionMutationRouteLease(
|
||||
patchSession: { key, expectedSessionID, label, category, pinned, archived, unread in
|
||||
patchSession: { key, expectedSessionID, expectedMarkedUnreadAt, label, category, pinned, archived, unread in
|
||||
guard unread != false || unreadAckContract != nil else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
let target = transport.sessionTarget(for: key)
|
||||
let request = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: target.sessionKey,
|
||||
@@ -86,7 +92,10 @@ extension MacGatewayChatTransport {
|
||||
category: category,
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
unreadPatch: .routed(
|
||||
unread: unread,
|
||||
expectedMarkedUnreadAt: expectedMarkedUnreadAt,
|
||||
supportsReadContract: unreadAckContract == true))
|
||||
_ = try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
|
||||
@@ -658,17 +658,18 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
archived: Bool?,
|
||||
unread: Bool?) async throws
|
||||
{
|
||||
let target = self.sessionTarget(for: key)
|
||||
let request = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
expectedSessionID: expectedSessionID,
|
||||
label: label,
|
||||
category: category,
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
_ = try await self.connection.request(request)
|
||||
if let routeLease = await self.acquireSessionMutationRouteLease() {
|
||||
try await routeLease.patchSession(
|
||||
key: key,
|
||||
expectedSessionID: expectedSessionID,
|
||||
label: label,
|
||||
category: category,
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
return
|
||||
}
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
|
||||
func deleteSession(key: String) async throws {
|
||||
|
||||
@@ -14,6 +14,26 @@ public struct OpenClawChatGatewayRequest: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenClawChatSessionUnreadPatch: Sendable, Equatable {
|
||||
case markUnread
|
||||
case read
|
||||
case automaticRead(expectedMarkedUnreadAt: Double?)
|
||||
|
||||
public static func routed(
|
||||
unread: Bool?,
|
||||
expectedMarkedUnreadAt: Double??,
|
||||
supportsReadContract: Bool) -> Self?
|
||||
{
|
||||
guard let unread else { return nil }
|
||||
guard !unread else { return .markUnread }
|
||||
guard supportsReadContract else { return .read }
|
||||
if let expectedMarkedUnreadAt {
|
||||
return .automaticRead(expectedMarkedUnreadAt: expectedMarkedUnreadAt)
|
||||
}
|
||||
return .read
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenClawChatSessionTargetPolicy: Sendable {
|
||||
case preserveBareKeys
|
||||
case scopeBareKeysToSelectedAgent
|
||||
@@ -342,7 +362,7 @@ public enum OpenClawChatGatewayRequests {
|
||||
category: String??,
|
||||
pinned: Bool?,
|
||||
archived: Bool?,
|
||||
unread: Bool?) -> OpenClawChatGatewayRequest
|
||||
unreadPatch: OpenClawChatSessionUnreadPatch?) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
var params = self.sessionParams(sessionKey: sessionKey, agentID: agentID)
|
||||
if let expectedSessionID = expectedSessionID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -362,8 +382,16 @@ public enum OpenClawChatGatewayRequests {
|
||||
if let archived {
|
||||
params["archived"] = AnyCodable(archived)
|
||||
}
|
||||
if let unread {
|
||||
params["unread"] = AnyCodable(unread)
|
||||
switch unreadPatch {
|
||||
case .markUnread:
|
||||
params["unread"] = AnyCodable(true)
|
||||
case .read:
|
||||
params["unread"] = AnyCodable(false)
|
||||
case let .automaticRead(expectedMarkedUnreadAt):
|
||||
params["unread"] = AnyCodable(false)
|
||||
params["expectedMarkedUnreadAt"] = expectedMarkedUnreadAt.map(AnyCodable.init) ?? AnyCodable(NSNull())
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
return OpenClawChatGatewayRequest(
|
||||
method: "sessions.patch",
|
||||
|
||||
@@ -2,6 +2,8 @@ import Foundation
|
||||
|
||||
struct ChatSessionUnreadPatchGuard {
|
||||
private var activeSessionKey = ""
|
||||
private var activationObserved = false
|
||||
private var activationMarkedUnreadAt: Double?
|
||||
private var requested = false
|
||||
private var activeExplicitUnread: Bool?
|
||||
private var confirmedUnreadByKey: [String: Bool] = [:]
|
||||
@@ -21,13 +23,21 @@ struct ChatSessionUnreadPatchGuard {
|
||||
}
|
||||
}
|
||||
|
||||
mutating func shouldPatch(key: String, unread: Bool?) -> Int? {
|
||||
mutating func shouldPatch(key: String, unread: Bool?, markedUnreadAt: Double?) -> Int? {
|
||||
guard !key.isEmpty else { return nil }
|
||||
self.activate(key: key)
|
||||
if !self.activationObserved {
|
||||
self.activationObserved = true
|
||||
self.activationMarkedUnreadAt = markedUnreadAt
|
||||
}
|
||||
if unread == false {
|
||||
self.activationMarkedUnreadAt = nil
|
||||
self.requested = false
|
||||
return nil
|
||||
}
|
||||
if let markedUnreadAt, markedUnreadAt != self.activationMarkedUnreadAt {
|
||||
return nil
|
||||
}
|
||||
guard unread == true, !self.requested else { return nil }
|
||||
self.requested = true
|
||||
return self.advanceRevision(key: key)
|
||||
@@ -36,6 +46,8 @@ struct ChatSessionUnreadPatchGuard {
|
||||
mutating func activate(key: String) {
|
||||
guard key != self.activeSessionKey else { return }
|
||||
self.activeSessionKey = key
|
||||
self.activationObserved = false
|
||||
self.activationMarkedUnreadAt = nil
|
||||
self.requested = false
|
||||
self.activeExplicitUnread = nil
|
||||
}
|
||||
@@ -110,6 +122,7 @@ final class ChatSessionUnreadMutationQueue {
|
||||
routeLease: Task<OpenClawChatSessionMutationRouteLease?, Never>,
|
||||
queueKey: String,
|
||||
routeKey: String,
|
||||
expectedMarkedUnreadAt: Double?? = nil,
|
||||
unread: Bool) -> Task<Void, Error>
|
||||
{
|
||||
let previous = self.tails[queueKey]?.task
|
||||
@@ -123,6 +136,7 @@ final class ChatSessionUnreadMutationQueue {
|
||||
}
|
||||
try await resolvedRouteLease.patchSession(
|
||||
key: routeKey,
|
||||
expectedMarkedUnreadAt: expectedMarkedUnreadAt,
|
||||
label: nil,
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
|
||||
@@ -379,6 +379,7 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
|
||||
public var space: String?
|
||||
public var updatedAt: Double?
|
||||
public var lastReadAt: Double?
|
||||
public var markedUnreadAt: Double?
|
||||
public var lastInteractionAt: Double?
|
||||
public var lastActivityAt: Double?
|
||||
public var sessionId: String?
|
||||
@@ -461,6 +462,7 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
|
||||
agentStatus: OpenClawChatSessionAgentStatus? = nil,
|
||||
observerDigest: OpenClawChatSessionObserverDigest? = nil,
|
||||
lastReadAt: Double? = nil,
|
||||
markedUnreadAt: Double? = nil,
|
||||
lastInteractionAt: Double? = nil,
|
||||
lastActivityAt: Double? = nil,
|
||||
parentSessionKey: String? = nil,
|
||||
@@ -510,6 +512,7 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
|
||||
self.space = space
|
||||
self.updatedAt = updatedAt
|
||||
self.lastReadAt = lastReadAt
|
||||
self.markedUnreadAt = markedUnreadAt
|
||||
self.lastInteractionAt = lastInteractionAt
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.sessionId = sessionId
|
||||
|
||||
@@ -364,6 +364,7 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
|
||||
public typealias PatchSession = @Sendable (
|
||||
_ key: String,
|
||||
_ expectedSessionID: String?,
|
||||
_ expectedMarkedUnreadAt: Double??,
|
||||
_ label: String??,
|
||||
_ category: String??,
|
||||
_ pinned: Bool?,
|
||||
@@ -385,6 +386,7 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
|
||||
public func patchSession(
|
||||
key: String,
|
||||
expectedSessionID: String? = nil,
|
||||
expectedMarkedUnreadAt: Double?? = nil,
|
||||
label: String??,
|
||||
category: String??,
|
||||
pinned: Bool?,
|
||||
@@ -394,6 +396,7 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
|
||||
try await self.patchSessionImpl(
|
||||
key,
|
||||
expectedSessionID,
|
||||
expectedMarkedUnreadAt,
|
||||
label,
|
||||
category,
|
||||
pinned,
|
||||
@@ -914,7 +917,7 @@ extension OpenClawChatTransport {
|
||||
public func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease? {
|
||||
let transport = self
|
||||
return OpenClawChatSessionMutationRouteLease(
|
||||
patchSession: { key, expectedSessionID, label, category, pinned, archived, unread in
|
||||
patchSession: { key, expectedSessionID, _, label, category, pinned, archived, unread in
|
||||
try await transport.patchSession(
|
||||
key: key,
|
||||
expectedSessionID: expectedSessionID,
|
||||
|
||||
@@ -831,7 +831,8 @@ extension OpenClawChatViewModel {
|
||||
let entry = self.currentSessionEntry() ?? fallbackEntry,
|
||||
let revision = self.unreadPatchGuard.shouldPatch(
|
||||
key: self.sessionMutationIdentity(for: entry.key, listedKey: entry.key),
|
||||
unread: entry.unread)
|
||||
unread: entry.unread,
|
||||
markedUnreadAt: entry.markedUnreadAt)
|
||||
else { return }
|
||||
let identityKey = self.sessionMutationIdentity(for: entry.key, listedKey: entry.key)
|
||||
let routeLease = Task { await self.transport.acquireSessionMutationRouteLease() }
|
||||
@@ -839,6 +840,7 @@ extension OpenClawChatViewModel {
|
||||
routeLease: routeLease,
|
||||
queueKey: identityKey,
|
||||
routeKey: entry.key,
|
||||
expectedMarkedUnreadAt: .some(entry.markedUnreadAt),
|
||||
unread: false)
|
||||
do {
|
||||
try await operation.value
|
||||
@@ -847,9 +849,7 @@ extension OpenClawChatViewModel {
|
||||
unread: false,
|
||||
revision: revision)
|
||||
else { return }
|
||||
if let index = self.sessions.firstIndex(where: { $0.key == entry.key }) {
|
||||
self.sessions[index].unread = false
|
||||
}
|
||||
self.refreshSessions()
|
||||
} catch {
|
||||
guard self.unreadPatchGuard.patchFailed(key: identityKey, revision: revision) else { return }
|
||||
chatSessionActionsLogger.error(
|
||||
|
||||
@@ -2,6 +2,7 @@ import OpenClawProtocol
|
||||
|
||||
public enum GatewayServerCapability: String, CaseIterable, Sendable {
|
||||
case chatSendRoutingContract = "chat-send-routing-contract"
|
||||
case sessionUnreadAckContract = "session-unread-ack-contract"
|
||||
case systemAgentSetupModelRef = "openclaw-setup-model-ref"
|
||||
}
|
||||
|
||||
|
||||
@@ -6266,6 +6266,7 @@ public struct SessionRow: Codable, Sendable {
|
||||
public let pinnedat: Double?
|
||||
public let unread: Bool?
|
||||
public let lastreadat: Double?
|
||||
public let markedunreadat: Double?
|
||||
public let lastactivityat: Double?
|
||||
public let lastinteractionat: Double?
|
||||
public let status: AnyCodable?
|
||||
@@ -6337,6 +6338,7 @@ public struct SessionRow: Codable, Sendable {
|
||||
pinnedat: Double? = nil,
|
||||
unread: Bool? = nil,
|
||||
lastreadat: Double? = nil,
|
||||
markedunreadat: Double? = nil,
|
||||
lastactivityat: Double? = nil,
|
||||
lastinteractionat: Double? = nil,
|
||||
status: AnyCodable? = nil,
|
||||
@@ -6407,6 +6409,7 @@ public struct SessionRow: Codable, Sendable {
|
||||
self.pinnedat = pinnedat
|
||||
self.unread = unread
|
||||
self.lastreadat = lastreadat
|
||||
self.markedunreadat = markedunreadat
|
||||
self.lastactivityat = lastactivityat
|
||||
self.lastinteractionat = lastinteractionat
|
||||
self.status = status
|
||||
@@ -6479,6 +6482,7 @@ public struct SessionRow: Codable, Sendable {
|
||||
case pinnedat = "pinnedAt"
|
||||
case unread
|
||||
case lastreadat = "lastReadAt"
|
||||
case markedunreadat = "markedUnreadAt"
|
||||
case lastactivityat = "lastActivityAt"
|
||||
case lastinteractionat = "lastInteractionAt"
|
||||
case status
|
||||
@@ -9416,6 +9420,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
public let expectedsessionid: String?
|
||||
public let expectedlifecyclerevision: String?
|
||||
public let expectedmarkedunreadat: AnyCodable?
|
||||
public let label: AnyCodable?
|
||||
public let icon: AnyCodable?
|
||||
public let category: AnyCodable?
|
||||
@@ -9453,6 +9458,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
agentid: String? = nil,
|
||||
expectedsessionid: String? = nil,
|
||||
expectedlifecyclerevision: String? = nil,
|
||||
expectedmarkedunreadat: AnyCodable? = nil,
|
||||
label: AnyCodable? = nil,
|
||||
icon: AnyCodable? = nil,
|
||||
category: AnyCodable? = nil,
|
||||
@@ -9489,6 +9495,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.expectedsessionid = expectedsessionid
|
||||
self.expectedlifecyclerevision = expectedlifecyclerevision
|
||||
self.expectedmarkedunreadat = expectedmarkedunreadat
|
||||
self.label = label
|
||||
self.icon = icon
|
||||
self.category = category
|
||||
@@ -9527,6 +9534,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case expectedsessionid = "expectedSessionId"
|
||||
case expectedlifecyclerevision = "expectedLifecycleRevision"
|
||||
case expectedmarkedunreadat = "expectedMarkedUnreadAt"
|
||||
case label
|
||||
case icon
|
||||
case category
|
||||
|
||||
@@ -103,7 +103,7 @@ struct ChatGatewayRequestTests {
|
||||
category: .some(nil),
|
||||
pinned: true,
|
||||
archived: nil,
|
||||
unread: false)
|
||||
unreadPatch: nil)
|
||||
|
||||
#expect(request.method == "sessions.patch")
|
||||
#expect(request.params["key"]?.value as? String == "global")
|
||||
@@ -111,10 +111,52 @@ struct ChatGatewayRequestTests {
|
||||
#expect(request.params["label"]?.value is NSNull)
|
||||
#expect(request.params["category"]?.value is NSNull)
|
||||
#expect(request.params["pinned"]?.value as? Bool == true)
|
||||
#expect(request.params["unread"]?.value as? Bool == false)
|
||||
#expect(request.params["archived"] == nil)
|
||||
}
|
||||
|
||||
@Test func `session read acknowledgement encodes an absent manual marker as null`() {
|
||||
let request = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: "main",
|
||||
agentID: nil,
|
||||
label: nil,
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
archived: nil,
|
||||
unreadPatch: .automaticRead(expectedMarkedUnreadAt: nil))
|
||||
|
||||
#expect(request.params["expectedMarkedUnreadAt"]?.value is NSNull)
|
||||
}
|
||||
|
||||
@Test func `explicit session read uses the legacy-compatible payload`() {
|
||||
let request = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: "main",
|
||||
agentID: nil,
|
||||
label: nil,
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
archived: nil,
|
||||
unreadPatch: .read)
|
||||
|
||||
#expect(request.params["unread"]?.value as? Bool == false)
|
||||
#expect(request.params["readIntent"] == nil)
|
||||
#expect(request.params["expectedMarkedUnreadAt"] == nil)
|
||||
}
|
||||
|
||||
@Test func `session read routing separates explicit automatic and legacy requests`() {
|
||||
#expect(OpenClawChatSessionUnreadPatch.routed(
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: nil,
|
||||
supportsReadContract: true) == .read)
|
||||
#expect(OpenClawChatSessionUnreadPatch.routed(
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: .some(nil),
|
||||
supportsReadContract: true) == .automaticRead(expectedMarkedUnreadAt: nil))
|
||||
#expect(OpenClawChatSessionUnreadPatch.routed(
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: .some(10),
|
||||
supportsReadContract: false) == .read)
|
||||
}
|
||||
|
||||
@Test func `settings patch request encodes default model as null`() {
|
||||
let request = OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -262,7 +304,7 @@ struct ChatGatewayRequestTests {
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
archived: nil,
|
||||
unread: nil)
|
||||
unreadPatch: nil)
|
||||
let archive = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: "agent:main:child",
|
||||
agentID: nil,
|
||||
@@ -271,7 +313,7 @@ struct ChatGatewayRequestTests {
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
archived: true,
|
||||
unread: nil)
|
||||
unreadPatch: nil)
|
||||
let restore = OpenClawChatGatewayRequests.patchSession(
|
||||
sessionKey: "agent:main:child",
|
||||
agentID: nil,
|
||||
@@ -280,7 +322,7 @@ struct ChatGatewayRequestTests {
|
||||
category: nil,
|
||||
pinned: nil,
|
||||
archived: false,
|
||||
unread: nil)
|
||||
unreadPatch: nil)
|
||||
let fork = OpenClawChatGatewayRequests.forkSession(
|
||||
parentSessionKey: "agent:main:child",
|
||||
agentID: nil)
|
||||
|
||||
@@ -398,6 +398,66 @@ struct ChatViewModelUnreadTests {
|
||||
#expect(attempts.map(\.1) == [true, false])
|
||||
}
|
||||
|
||||
@Test func `manual unread from another client survives refresh until reactivation`() async throws {
|
||||
let transport = UnreadTestTransport(sessions: [
|
||||
self.entry(key: "a", unread: false),
|
||||
self.entry(key: "b", unread: false),
|
||||
])
|
||||
let viewModel = self.viewModel(sessionKey: "a", transport: transport)
|
||||
|
||||
viewModel.load()
|
||||
try await self.waitForUnreadState("initial read session loaded") {
|
||||
!viewModel.isLoading && viewModel.sessionId == "session-a"
|
||||
}
|
||||
await transport.setSessions([
|
||||
self.entry(key: "a", unread: true, markedUnreadAt: 100),
|
||||
self.entry(key: "b", unread: false),
|
||||
])
|
||||
viewModel.refresh()
|
||||
try await self.waitForUnreadState("manual unread refresh settled") {
|
||||
await transport.historyCallCount() >= 2 && !viewModel.isLoading
|
||||
}
|
||||
#expect(await transport.unreadPatchAttempts().isEmpty)
|
||||
|
||||
viewModel.switchSession(to: "b")
|
||||
try await self.waitForUnreadState("other session activated") {
|
||||
viewModel.sessionId == "session-b"
|
||||
}
|
||||
viewModel.switchSession(to: "a")
|
||||
try await self.waitForUnreadState("manual unread acknowledged on reactivation") {
|
||||
await transport.unreadPatchAttempts().count == 1
|
||||
}
|
||||
|
||||
let attempts = await transport.unreadPatchAttempts()
|
||||
#expect(attempts.map(\.0) == ["a"])
|
||||
#expect(attempts.map(\.1) == [false])
|
||||
}
|
||||
|
||||
@Test func `newer manual unread remains visible when activation acknowledgement loses race`() async throws {
|
||||
let patchGate = UnreadPatchGate()
|
||||
let transport = UnreadTestTransport(
|
||||
sessions: [self.entry(key: "a", unread: true, markedUnreadAt: 100)],
|
||||
patchGate: patchGate)
|
||||
let viewModel = self.viewModel(sessionKey: "a", transport: transport)
|
||||
|
||||
viewModel.load()
|
||||
try await self.waitForUnreadState("activation acknowledgement started") {
|
||||
await transport.unreadPatchStartCount() == 1
|
||||
}
|
||||
await transport.setSessions([
|
||||
self.entry(key: "a", unread: true, markedUnreadAt: 101),
|
||||
])
|
||||
await patchGate.release()
|
||||
try await self.waitForUnreadState("authoritative unread refresh applied") {
|
||||
await transport.listCallCount() >= 2 &&
|
||||
viewModel.sessions.first(where: { $0.key == "a" })?.markedUnreadAt == 101
|
||||
}
|
||||
|
||||
let session = try #require(viewModel.sessions.first(where: { $0.key == "a" }))
|
||||
#expect(session.unread == true)
|
||||
#expect(session.markedUnreadAt == 101)
|
||||
}
|
||||
|
||||
@Test func `successful off-list mark read records read confirmation`() async throws {
|
||||
let transport = UnreadTestTransport(sessions: [])
|
||||
let viewModel = self.viewModel(sessionKey: "a", transport: transport)
|
||||
@@ -413,12 +473,12 @@ struct ChatViewModelUnreadTests {
|
||||
@Test func `failed route lease preserves mutation queue ordering`() async throws {
|
||||
let recorder = UnreadMutationRecorder()
|
||||
let queue = ChatSessionUnreadMutationQueue()
|
||||
let firstLease = OpenClawChatSessionMutationRouteLease { _, _, _, _, _, _, _ in
|
||||
let firstLease = OpenClawChatSessionMutationRouteLease { _, _, _, _, _, _, _, _ in
|
||||
await recorder.append("first-start")
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
await recorder.append("first-end")
|
||||
}
|
||||
let thirdLease = OpenClawChatSessionMutationRouteLease { _, _, _, _, _, _, _ in
|
||||
let thirdLease = OpenClawChatSessionMutationRouteLease { _, _, _, _, _, _, _, _ in
|
||||
await recorder.append("third")
|
||||
}
|
||||
|
||||
@@ -609,6 +669,7 @@ struct ChatViewModelUnreadTests {
|
||||
key: String,
|
||||
unread: Bool,
|
||||
updatedAt: Double = 1,
|
||||
markedUnreadAt: Double? = nil,
|
||||
lastInteractionAt: Double? = nil,
|
||||
lastActivityAt: Double? = nil,
|
||||
pinned: Bool? = nil) -> OpenClawChatSessionEntry
|
||||
@@ -635,6 +696,7 @@ struct ChatViewModelUnreadTests {
|
||||
contextTokens: nil,
|
||||
pinned: pinned,
|
||||
unread: unread,
|
||||
markedUnreadAt: markedUnreadAt,
|
||||
lastInteractionAt: lastInteractionAt,
|
||||
lastActivityAt: lastActivityAt)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ Disable the feature entirely with **Settings → General → Quick Chat**; the s
|
||||
- `chat.history` returns a display-normalized transcript: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, including truncated blocks) and leaked model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY`/`no_reply` are omitted, and oversized rows can be replaced with a truncated placeholder.
|
||||
- Session: defaults to the primary session as above; the UI can switch between sessions.
|
||||
- Session groups: `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` own the path-free group catalog. Write-scoped `sessions.groups.defaults` and `sessions.groups.update` own optional New Session folder/worktree defaults. Membership is the session `category` updated through `sessions.patch` or assigned during `sessions.create`.
|
||||
- Unread state: after a session activates and its live history loads successfully, the app clears that session's unread marker. Failed history loads do not clear it; a transient patch failure retries on the next activation.
|
||||
- Unread state: after a session activates and its live history loads successfully, the app clears the unread state it observed. A manual unread marker created while that session is already open remains through refreshes and run completion; leave and reopen the session, or mark it read explicitly, to clear it. Failed history loads do not clear unread state, and a transient patch failure retries on the next activation. During staggered upgrades, an older active app can still send a bare read acknowledgement that clears the marker. Cross-client protection therefore requires every active app to support the acknowledgement contract; update all connected clients before relying on the reminder.
|
||||
- Onboarding uses a dedicated session to keep first-run setup separate.
|
||||
- Offline cache: the app keeps a small read-only cache of recent chat sessions and transcripts per gateway (`~/Library/Application Support/OpenClaw/chat-cache.sqlite`): cold opens paint the last known transcript immediately and refresh once the Gateway responds, and recent chats stay browsable while disconnected (sending stays disabled until the connection is back).
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ The runtime store keeps `SessionEntry` values in per-agent SQLite. The value typ
|
||||
- `pinnedAt`: optional pin timestamp. Active pinned sessions sort ahead of unpinned sessions; archiving a session clears its pin.
|
||||
- Codex thread interop: both fields follow the Codex thread-management shape - the `archived`/`pinned` booleans on the wire are always derived from the timestamp and stamped server-side, matching Codex `threads.archived_at` semantics and camelCase serialization. OpenClaw timestamps are epoch milliseconds while Codex uses epoch seconds, so bridges convert at the `codex` plugin seam. Codex has no pin API yet (`thread/archive`/`thread/unarchive` only); pinned state stays OpenClaw-side until one exists, at which point the matching shape lets bound sessions round-trip pin state mechanically.
|
||||
- Codex supervision lists only non-archived native threads. A Gateway-local `idle` or `notLoaded` activity-unknown thread can be archived through native `thread/archive` only after the operator explicitly confirms that no other Codex process owns it; the plugin performs a fresh process-local status read first, and the thread then disappears from the catalog. That read cannot prove that another App Server process is not using the thread. OpenClaw refuses to archive active and error rows, and paired-node archive is unavailable until the node bridge can own the full streamed thread lifecycle. Unarchiving in a native Codex client makes the thread eligible to appear again.
|
||||
- `lastReadAt` / `markedUnreadAt`: read-state timestamps stamped server-side by `sessions.patch { unread }` - `unread: false` records a read (sets `lastReadAt`, clears `markedUnreadAt`); `unread: true` marks the session unread until the next read. Session rows expose a derived `unread` boolean: explicitly marked unread, or read before the latest activity. Sessions never marked read stay `unread: false`, so existing installs do not light up on upgrade.
|
||||
- `lastReadAt` / `markedUnreadAt`: read-state timestamps stamped server-side by `sessions.patch { unread }` - `unread: false` records a read (sets `lastReadAt`, clears `markedUnreadAt`); `unread: true` records `markedUnreadAt` and marks the session unread until the next activation or explicit read. Session rows expose the marker alongside a derived `unread` boolean so already-open clients preserve manual reminders while still acknowledging new activity. Automatic read patches from clients that support the advertised unread acknowledgement contract include `expectedMarkedUnreadAt` (`null` means no marker); a newer marker makes that acknowledgement a successful no-op instead of erasing newer intent. Bare `unread: false` requests retain the legacy clear behavior, so protection across several connected clients requires each active client to support the contract. Sessions never marked read stay `unread: false`, so existing installs do not light up on upgrade.
|
||||
- `lastActivityAt`: timestamp of the last completed agent run that counts as unread-worthy activity (user, channel, and cron runs). Heartbeat and internal-event turns, plus metadata patches, do not update it; `updatedAt` is not an activity signal.
|
||||
- `sessionFile`: legacy marker retained for migration/archive compatibility; active runtime uses SQLite identity
|
||||
- `chatType`: `direct | group | room`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -180,21 +180,6 @@ describe("lazy protocol validators", () => {
|
||||
expectRejected(validateSessionsListParams, [{ face: "dashboard" }]);
|
||||
});
|
||||
|
||||
it("validates session patch compare-and-swap identity", () => {
|
||||
expectAccepted(validateSessionsPatchParams, [
|
||||
sessionPatch({
|
||||
key: "agent:main:self-archive",
|
||||
archived: true,
|
||||
expectedSessionId: "session-self-archive",
|
||||
expectedLifecycleRevision: "revision-self-archive",
|
||||
}),
|
||||
]);
|
||||
expectRejected(validateSessionsPatchParams, [
|
||||
sessionPatch({ key: "agent:main:self-archive", expectedSessionId: "" }),
|
||||
sessionPatch({ key: "agent:main:self-archive", expectedLifecycleRevision: "" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates bounded closed bulk session patch requests", () => {
|
||||
expect(protocol.SESSIONS_PATCH_MANY_MAX_TARGETS).toBe(100);
|
||||
const target = {
|
||||
|
||||
@@ -7,16 +7,7 @@ import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
|
||||
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
import { WorkerAdmissionHandshakeSchema } from "./worker-admission.js";
|
||||
|
||||
export const GATEWAY_SERVER_CAPS = {
|
||||
BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc",
|
||||
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
|
||||
GATEWAY_RESTART_TARGET_SAFE: "gateway-restart-target-safe-v1",
|
||||
NODE_WORKER_BUNDLE_RETENTION: "node-worker-bundle-retention-v1",
|
||||
NODE_WORKER_BUNDLE_STATUS: "node-worker-bundle-status-v1",
|
||||
SYSTEM_AGENT_WIZARD_CANCEL: "openclaw-chat-wizard-cancel",
|
||||
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
|
||||
TASK_SUGGESTIONS_ACCEPT_MODES: "taskSuggestions.acceptModes",
|
||||
} as const;
|
||||
export { GATEWAY_SERVER_CAPS } from "../server-capabilities.js";
|
||||
|
||||
/**
|
||||
* Top-level gateway frame schemas.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateSessionsPatchParams } from "../index.js";
|
||||
|
||||
describe("session patch schema", () => {
|
||||
it("validates lifecycle and unread acknowledgement identities", () => {
|
||||
expect(
|
||||
validateSessionsPatchParams({
|
||||
key: "agent:main:self-archive",
|
||||
archived: true,
|
||||
expectedSessionId: "session-self-archive",
|
||||
expectedLifecycleRevision: "revision-self-archive",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateSessionsPatchParams({
|
||||
key: "agent:main:mark-read",
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: 42,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateSessionsPatchParams({ key: "agent:main:self-archive", expectedSessionId: "" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateSessionsPatchParams({
|
||||
key: "agent:main:self-archive",
|
||||
expectedLifecycleRevision: "",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateSessionsPatchParams({
|
||||
key: "agent:main:mark-read",
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: -1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,13 @@ import { SessionPermissionModeSchema, SessionToolOverridesSchema } from "./sessi
|
||||
|
||||
export const SESSIONS_PATCH_MANY_MAX_TARGETS = 100;
|
||||
|
||||
const ExpectedMarkedUnreadAt = Type.Optional(
|
||||
Type.Union([Type.Number({ minimum: 0 }), Type.Null()], {
|
||||
description:
|
||||
"Apply an automatic unread=false acknowledgement only if the explicit unread marker still matches; null asserts no marker.",
|
||||
}),
|
||||
);
|
||||
|
||||
const SessionsPatchMutationProperties = {
|
||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||
icon: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
@@ -69,6 +76,7 @@ export const SessionsPatchParamsSchema = closedObject({
|
||||
/** Reject the mutation if the session was reset or replaced before it commits. */
|
||||
expectedSessionId: Type.Optional(NonEmptyString),
|
||||
expectedLifecycleRevision: Type.Optional(NonEmptyString),
|
||||
expectedMarkedUnreadAt: ExpectedMarkedUnreadAt,
|
||||
...SessionsPatchMutationProperties,
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export const SessionRowSchema = Type.Object(
|
||||
pinnedAt: Type.Optional(Type.Number()),
|
||||
unread: Type.Optional(Type.Boolean()),
|
||||
lastReadAt: Type.Optional(Type.Number()),
|
||||
markedUnreadAt: Type.Optional(Type.Number()),
|
||||
lastActivityAt: Type.Optional(Type.Number()),
|
||||
lastInteractionAt: Type.Optional(Type.Number()),
|
||||
status: Type.Optional(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Stable feature names advertised in Gateway hello responses. */
|
||||
export const GATEWAY_SERVER_CAPS = {
|
||||
BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc",
|
||||
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
|
||||
GATEWAY_RESTART_TARGET_SAFE: "gateway-restart-target-safe-v1",
|
||||
NODE_WORKER_BUNDLE_RETENTION: "node-worker-bundle-retention-v1",
|
||||
NODE_WORKER_BUNDLE_STATUS: "node-worker-bundle-status-v1",
|
||||
SESSION_UNREAD_ACK_CONTRACT: "session-unread-ack-contract",
|
||||
SYSTEM_AGENT_WIZARD_CANCEL: "openclaw-chat-wizard-cancel",
|
||||
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
|
||||
TASK_SUGGESTIONS_ACCEPT_MODES: "taskSuggestions.acceptModes",
|
||||
} as const;
|
||||
@@ -238,7 +238,7 @@ describe("gateway agent handler", () => {
|
||||
},
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
};
|
||||
} satisfies typeof mocks.loadConfigReturn;
|
||||
mocks.listAgentIds.mockReturnValue(["main", "work"]);
|
||||
mocks.loadConfigReturn = cfg;
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
@@ -539,7 +539,7 @@ describe("gateway agent handler", () => {
|
||||
const childSessionKey = "agent:main:subagent:registry-fail";
|
||||
const cfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
};
|
||||
} satisfies typeof mocks.loadConfigReturn;
|
||||
mocks.loadConfigReturn = cfg;
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SubagentRegistryDeps } from "../../agents/subagents/registry/subag
|
||||
import { resetSubagentRegistryForTests } from "../../agents/subagents/registry/subagent-registry.test-helpers.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { SessionTranscriptStats } from "../../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resetDiagnosticEventsForTest } from "../../infra/diagnostic-events.js";
|
||||
import {
|
||||
resetDetachedTaskLifecycleRuntimeForTests,
|
||||
@@ -58,7 +59,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveAgentExplicitRecipientSession: vi.fn(async () => ({})),
|
||||
readAcpSessionMeta: vi.fn<typeof readAcpSessionMeta>(() => undefined),
|
||||
listAgentIds: vi.fn(() => ["main"]),
|
||||
loadConfigReturn: {} as Record<string, unknown>,
|
||||
loadConfigReturn: {} as OpenClawConfig,
|
||||
loadVoiceWakeRoutingConfig: vi.fn(),
|
||||
resolveVoiceWakeRouteByTrigger: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
@@ -78,11 +79,35 @@ export function getAgentTestMocks() {
|
||||
return mocks;
|
||||
}
|
||||
|
||||
function resolveAgentTestConfig(cfg: OpenClawConfig = mocks.loadConfigReturn): OpenClawConfig {
|
||||
if (cfg.agents?.list) {
|
||||
return cfg;
|
||||
}
|
||||
const agentIds = mocks.listAgentIds();
|
||||
if (agentIds.length === 1 && agentIds[0] === "main") {
|
||||
return cfg;
|
||||
}
|
||||
const resolved = {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
list: agentIds.map((id) => ({ id })),
|
||||
},
|
||||
};
|
||||
if (cfg === mocks.loadConfigReturn) {
|
||||
mocks.loadConfigReturn = resolved;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
vi.mock("../session-utils.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
|
||||
return {
|
||||
...actual,
|
||||
loadSessionEntry: mocks.loadSessionEntry,
|
||||
loadSessionEntry: (...args: Parameters<typeof actual.loadSessionEntry>) => {
|
||||
const loaded = mocks.loadSessionEntry(...args) as ReturnType<typeof actual.loadSessionEntry>;
|
||||
return { ...loaded, cfg: resolveAgentTestConfig(loaded.cfg) };
|
||||
},
|
||||
loadGatewaySessionRow: mocks.loadGatewaySessionRow,
|
||||
};
|
||||
});
|
||||
@@ -170,7 +195,7 @@ vi.mock("../../agents/prepared-model-runtime.js", () => ({
|
||||
// that production publishes before admitting agent RPCs.
|
||||
loadPublishedGatewayReplyDispatchRuntime: async ({ agentId }: { agentId: string }) => ({
|
||||
agentId,
|
||||
config: mocks.loadConfigReturn,
|
||||
config: resolveAgentTestConfig(),
|
||||
pluginGeneration: { pluginMetadataSnapshot: {} },
|
||||
}),
|
||||
}));
|
||||
@@ -190,7 +215,7 @@ vi.mock("../../config/config.js", async () => {
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeConfig: () => mocks.loadConfigReturn,
|
||||
getRuntimeConfig: () => resolveAgentTestConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -367,7 +392,7 @@ export const makeContext = (): GatewayRequestContext =>
|
||||
logGateway: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
broadcastToConnIds: vi.fn(),
|
||||
getSessionEventSubscriberConnIds: () => new Set(),
|
||||
getRuntimeConfig: () => mocks.loadConfigReturn,
|
||||
getRuntimeConfig: () => resolveAgentTestConfig(),
|
||||
}) as unknown as GatewayRequestContext;
|
||||
|
||||
type AgentHandler = NonNullable<typeof agentHandlers.agent>;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { SessionsPatchParams } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
|
||||
export type SessionPatchTargetIdentity = Pick<
|
||||
SessionsPatchParams,
|
||||
"agentId" | "expectedLifecycleRevision" | "expectedMarkedUnreadAt" | "expectedSessionId" | "key"
|
||||
>;
|
||||
|
||||
const CONDITIONAL_UNREAD_ACK_ALLOWED_KEYS = new Set([
|
||||
"agentId",
|
||||
"expectedLifecycleRevision",
|
||||
"expectedMarkedUnreadAt",
|
||||
"expectedSessionId",
|
||||
"key",
|
||||
"unread",
|
||||
]);
|
||||
|
||||
function hasOtherMutation(patch: { unread?: boolean }): boolean {
|
||||
return Object.entries(patch).some(
|
||||
([key, value]) => value !== undefined && !CONDITIONAL_UNREAD_ACK_ALLOWED_KEYS.has(key),
|
||||
);
|
||||
}
|
||||
|
||||
export function validateSessionUnreadAck(
|
||||
patch: { unread?: boolean },
|
||||
target: Pick<SessionPatchTargetIdentity, "expectedMarkedUnreadAt">,
|
||||
): string | undefined {
|
||||
if (target.expectedMarkedUnreadAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (patch.unread === false && !hasOtherMutation(patch)) {
|
||||
return undefined;
|
||||
}
|
||||
return "expectedMarkedUnreadAt requires unread=false as the only mutation.";
|
||||
}
|
||||
|
||||
export function resolveSessionUnreadAck(
|
||||
entry: SessionEntry | undefined,
|
||||
patch: Pick<SessionsPatchParams, "expectedMarkedUnreadAt" | "unread">,
|
||||
): { kind: "apply" | "missing" } | { kind: "stale"; entry: SessionEntry } {
|
||||
const { expectedMarkedUnreadAt } = patch;
|
||||
if (patch.unread !== false || hasOtherMutation(patch) || expectedMarkedUnreadAt === undefined) {
|
||||
return { kind: "apply" };
|
||||
}
|
||||
if (!entry) {
|
||||
return { kind: "missing" };
|
||||
}
|
||||
return (entry.markedUnreadAt ?? null) === expectedMarkedUnreadAt
|
||||
? { kind: "apply" }
|
||||
: { kind: "stale", entry };
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
errorShape,
|
||||
type ErrorShape,
|
||||
type SessionsPatchManyResult,
|
||||
type SessionsPatchManyTarget,
|
||||
type SessionsPatchParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
@@ -36,6 +35,7 @@ import { projectSessionsPatchEntry } from "../sessions-patch.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
|
||||
import * as sessionUnreadAck from "./session-unread-ack.js";
|
||||
import {
|
||||
prepareSessionPatchArchive,
|
||||
type SessionPatchArchivePreparation,
|
||||
@@ -49,10 +49,8 @@ import type {
|
||||
SessionMutationAuthorization,
|
||||
} from "./types.js";
|
||||
|
||||
type PatchTargetIdentity = Pick<
|
||||
SessionsPatchManyTarget,
|
||||
"agentId" | "expectedLifecycleRevision" | "expectedSessionId" | "key"
|
||||
>;
|
||||
type PatchTargetIdentity = sessionUnreadAck.SessionPatchTargetIdentity;
|
||||
const { resolveSessionUnreadAck, validateSessionUnreadAck } = sessionUnreadAck;
|
||||
|
||||
type MutationTarget = PatchTargetIdentity & {
|
||||
commitGuard: () => ErrorShape | undefined;
|
||||
@@ -73,7 +71,9 @@ type PreparedPatchTarget = {
|
||||
targetAgentId: string;
|
||||
};
|
||||
|
||||
type MutationOutcome = { ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape };
|
||||
type MutationOutcome =
|
||||
| { ok: true; applied: boolean; entry: SessionEntry }
|
||||
| { ok: false; error: ErrorShape };
|
||||
|
||||
type ModelCatalog = Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalog"]>>;
|
||||
|
||||
@@ -177,6 +177,14 @@ async function executeSessionPatchMutations(params: {
|
||||
length: params.targets.length,
|
||||
});
|
||||
for (const [index, { input, key, requestedAgent, resolved }] of preflightTargets.entries()) {
|
||||
const unreadAckError = validateSessionUnreadAck(params.patch, input);
|
||||
if (unreadAckError) {
|
||||
outcomes[index] = {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, unreadAckError),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!requestedAgent.ok) {
|
||||
outcomes[index] = requestedAgent;
|
||||
continue;
|
||||
@@ -434,6 +442,28 @@ async function executeSessionPatchMutations(params: {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const unreadAck = resolveSessionUnreadAck(existingEntry, target.fullPatch);
|
||||
if (unreadAck.kind === "missing") {
|
||||
projectedOutcomes.push({
|
||||
ok: false,
|
||||
error: sessionChangedError(target.key),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (unreadAck.kind === "stale") {
|
||||
const authorizationFailure = params.targets[target.index]!.commitGuard();
|
||||
if (authorizationFailure) {
|
||||
projectedOutcomes.push({ ok: false, error: authorizationFailure });
|
||||
continue;
|
||||
}
|
||||
// A newer explicit marker owns the session until a later activation.
|
||||
projectedOutcomes.push({
|
||||
ok: true,
|
||||
applied: false,
|
||||
entry: unreadAck.entry,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const projected = await projectSessionsPatchEntry({
|
||||
cfg,
|
||||
creation,
|
||||
@@ -486,6 +516,7 @@ async function executeSessionPatchMutations(params: {
|
||||
);
|
||||
projectedOutcomes.push({
|
||||
ok: true,
|
||||
applied: true,
|
||||
entry: cloned,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -530,7 +561,7 @@ async function executeSessionPatchMutations(params: {
|
||||
const archivedSessionKeys = new Set<string>();
|
||||
for (const target of prepared) {
|
||||
const outcome = outcomes[target.index];
|
||||
if (!outcome?.ok) {
|
||||
if (!outcome?.ok || !outcome.applied) {
|
||||
continue;
|
||||
}
|
||||
triggerSessionPatchHook({
|
||||
@@ -657,6 +688,7 @@ export async function executeSessionPatch(params: {
|
||||
...(params.patch.expectedLifecycleRevision !== undefined
|
||||
? { expectedLifecycleRevision: params.patch.expectedLifecycleRevision }
|
||||
: {}),
|
||||
expectedMarkedUnreadAt: params.patch.expectedMarkedUnreadAt,
|
||||
};
|
||||
const executed = await executeSessionPatchMutations({
|
||||
client: params.client,
|
||||
|
||||
@@ -1116,23 +1116,67 @@ test("sessions.changed mutation events include session management metadata", asy
|
||||
key: "discord:group:dev",
|
||||
unread: true,
|
||||
});
|
||||
expectChangedBroadcast(unread.broadcastToConnIds, {
|
||||
const unreadPayload = expectChangedBroadcast(unread.broadcastToConnIds, {
|
||||
sessionKey: "agent:main:discord:group:dev",
|
||||
reason: "patch",
|
||||
unread: true,
|
||||
lastReadAt: 20,
|
||||
markedUnreadAt: expect.any(Number),
|
||||
lastActivityAt: 5,
|
||||
});
|
||||
|
||||
const marker = expectDefined(
|
||||
unreadPayload.markedUnreadAt as number | undefined,
|
||||
"manual unread marker",
|
||||
);
|
||||
expect(marker).toEqual(expect.any(Number));
|
||||
|
||||
const staleRead = await invokeSessionsPatch({
|
||||
key: "discord:group:dev",
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: null,
|
||||
});
|
||||
expectFields(staleRead.responsePayload, { ok: true, key: "agent:main:discord:group:dev" });
|
||||
expect(staleRead.broadcastToConnIds).not.toHaveBeenCalled();
|
||||
expect(requireRecord(staleRead.responsePayload.entry, "stale read entry").markedUnreadAt).toBe(
|
||||
marker,
|
||||
);
|
||||
|
||||
const read = await invokeSessionsPatch({
|
||||
key: "discord:group:dev",
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: marker,
|
||||
});
|
||||
expectChangedBroadcast(read.broadcastToConnIds, {
|
||||
sessionKey: "agent:main:discord:group:dev",
|
||||
reason: "patch",
|
||||
unread: false,
|
||||
lastReadAt: expect.any(Number),
|
||||
markedUnreadAt: null,
|
||||
lastActivityAt: 5,
|
||||
});
|
||||
|
||||
const remarked = await invokeSessionsPatch({
|
||||
key: "discord:group:dev",
|
||||
unread: true,
|
||||
});
|
||||
expectChangedBroadcast(remarked.broadcastToConnIds, {
|
||||
sessionKey: "agent:main:discord:group:dev",
|
||||
reason: "patch",
|
||||
unread: true,
|
||||
markedUnreadAt: expect.any(Number),
|
||||
});
|
||||
|
||||
const legacyRead = await invokeSessionsPatch({
|
||||
key: "discord:group:dev",
|
||||
unread: false,
|
||||
});
|
||||
expectChangedBroadcast(legacyRead.broadcastToConnIds, {
|
||||
sessionKey: "agent:main:discord:group:dev",
|
||||
reason: "patch",
|
||||
unread: false,
|
||||
lastReadAt: expect.any(Number),
|
||||
markedUnreadAt: null,
|
||||
lastActivityAt: 5,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,6 +288,89 @@ test.each([
|
||||
expect(loadSessionEntry({ sessionKey, storePath })).not.toHaveProperty("label");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "automatic acknowledgement with another mutation",
|
||||
fields: { expectedMarkedUnreadAt: 9, label: "Must not be discarded" },
|
||||
message: "expectedMarkedUnreadAt requires unread=false as the only mutation.",
|
||||
},
|
||||
] as const)("sessions.patch rejects $name", async ({ fields, message }) => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const sessionKey = "agent:main:conditional-unread-label";
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
[sessionKey]: sessionStoreEntry("conditional-unread-label", { markedUnreadAt: 10 }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await directSessionReq("sessions.patch", {
|
||||
key: sessionKey,
|
||||
unread: false,
|
||||
...fields,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message,
|
||||
},
|
||||
});
|
||||
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
|
||||
markedUnreadAt: 10,
|
||||
sessionId: "conditional-unread-label",
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.patch keeps explicit unread markers strictly advancing", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const sessionKey = "agent:main:conditional-unread-revision";
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
[sessionKey]: sessionStoreEntry("conditional-unread-revision"),
|
||||
},
|
||||
});
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
|
||||
try {
|
||||
await directSessionReq("sessions.patch", { key: sessionKey, unread: true });
|
||||
const firstMarker = loadSessionEntry({ sessionKey, storePath })?.markedUnreadAt;
|
||||
await directSessionReq("sessions.patch", { key: sessionKey, unread: true });
|
||||
const secondMarker = loadSessionEntry({ sessionKey, storePath })?.markedUnreadAt;
|
||||
|
||||
expect(firstMarker).toBe(100);
|
||||
expect(secondMarker).toBe(101);
|
||||
const staleRead = await directSessionReq("sessions.patch", {
|
||||
key: sessionKey,
|
||||
unread: false,
|
||||
expectedMarkedUnreadAt: firstMarker,
|
||||
});
|
||||
expect(staleRead).toMatchObject({ ok: true });
|
||||
expect(loadSessionEntry({ sessionKey, storePath })?.markedUnreadAt).toBe(secondMarker);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.patch preserves legacy read semantics for manual markers", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const sessionKey = "agent:main:mixed-version-unread";
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
[sessionKey]: sessionStoreEntry("mixed-version-unread", { markedUnreadAt: 10 }),
|
||||
},
|
||||
});
|
||||
|
||||
const legacyRead = await directSessionReq("sessions.patch", {
|
||||
key: sessionKey,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
expect(legacyRead).toMatchObject({ ok: true });
|
||||
expect(loadSessionEntry({ sessionKey, storePath })?.markedUnreadAt).toBeUndefined();
|
||||
expect(loadSessionEntry({ sessionKey, storePath })?.lastReadAt).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
test("sessions.patch archives the expected session under its lifecycle lock", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const sessionKey = "agent:main:subagent:archive-identity";
|
||||
|
||||
@@ -140,6 +140,7 @@ export async function sendGatewayHello(
|
||||
GATEWAY_SERVER_CAPS.GATEWAY_RESTART_TARGET_SAFE,
|
||||
GATEWAY_SERVER_CAPS.NODE_WORKER_BUNDLE_RETENTION,
|
||||
GATEWAY_SERVER_CAPS.NODE_WORKER_BUNDLE_STATUS,
|
||||
GATEWAY_SERVER_CAPS.SESSION_UNREAD_ACK_CONTRACT,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
|
||||
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HelloOk } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
GATEWAY_SERVER_CAPS,
|
||||
type HelloOk,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
// Hello update-scope tests cover authenticated role/scope and recovery ownership projection.
|
||||
|
||||
@@ -190,6 +193,9 @@ describe("sendGatewayHello update detail scope", () => {
|
||||
expect(helloPayload(context)?.server.buildId).toBe("build-a");
|
||||
expect(helloPayload(context)?.server.bootId).toBe("gateway-boot-a");
|
||||
expect(helloPayload(context)?.server.controlUiBuildSource).toBe("bundled");
|
||||
expect(helloPayload(context)?.features.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.SESSION_UNREAD_ACK_CONTRACT,
|
||||
);
|
||||
});
|
||||
|
||||
it("omits package build identity for independently built configured UI roots", async () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ export function buildGatewaySessionEventFields(params: {
|
||||
pinnedAt: sessionRow.pinnedAt ?? null,
|
||||
unread: sessionRow.unread ?? false,
|
||||
lastReadAt: sessionRow.lastReadAt,
|
||||
markedUnreadAt: sessionRow.markedUnreadAt ?? null,
|
||||
agentStatus: sessionRow.agentStatus ?? null,
|
||||
observerDigest: sessionRow.observerDigest ?? null,
|
||||
lastActivityAt: sessionRow.lastActivityAt,
|
||||
|
||||
@@ -598,6 +598,7 @@ export function buildGatewaySessionRow(params: {
|
||||
pinnedAt: entry?.pinnedAt,
|
||||
unread: deriveSessionUnread(entry),
|
||||
lastReadAt: entry?.lastReadAt,
|
||||
markedUnreadAt: entry?.markedUnreadAt,
|
||||
agentStatus,
|
||||
observerDigest: observerDigest
|
||||
? {
|
||||
|
||||
@@ -359,6 +359,7 @@ describe("gateway session utils", () => {
|
||||
entry: entry as SessionEntry,
|
||||
});
|
||||
expect(row.unread).toBe(expected);
|
||||
expect(row.markedUnreadAt).toBe(entry.markedUnreadAt);
|
||||
});
|
||||
|
||||
test("projects swarm collector group ids to list and live session payloads", () => {
|
||||
|
||||
@@ -124,6 +124,7 @@ export type GatewaySessionRow = {
|
||||
pinnedAt?: number;
|
||||
unread?: boolean;
|
||||
lastReadAt?: number;
|
||||
markedUnreadAt?: number;
|
||||
agentStatus?: SessionEntry["agentStatus"];
|
||||
observerDigest?: Pick<
|
||||
SessionObserverDigest,
|
||||
|
||||
@@ -394,7 +394,9 @@ export async function projectSessionsPatchEntry(params: {
|
||||
|
||||
if ("unread" in patch) {
|
||||
if (patch.unread === true) {
|
||||
next.markedUnreadAt = now;
|
||||
// This timestamp is also the conditional-ack revision. Repeated writes in
|
||||
// one clock tick must still represent distinct manual unread intent.
|
||||
next.markedUnreadAt = Math.max(now, (params.existingEntry?.markedUnreadAt ?? 0) + 1);
|
||||
} else {
|
||||
next.lastReadAt = now;
|
||||
delete next.markedUnreadAt;
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("./ssh-client.js", () => ({
|
||||
resolveSshClient: mocks.resolveSshClient,
|
||||
}));
|
||||
|
||||
import { getFreePort } from "../test-utils/ports.js";
|
||||
import { PortInUseError } from "./ports.js";
|
||||
import { parseSshTarget, startSshPortForward } from "./ssh-tunnel.js";
|
||||
|
||||
@@ -260,10 +261,11 @@ describe("startSshPortForward", () => {
|
||||
// Under fake timers neither advances, so a listener that loses the race on the
|
||||
// first probe hangs to the suite timeout instead of failing on its own budget.
|
||||
spawnFakeSshListening();
|
||||
const localPort = await getFreePort();
|
||||
|
||||
const tunnel = await startSshPortForward({
|
||||
target: "me@example.com:2222",
|
||||
localPortPreferred: 43210,
|
||||
localPortPreferred: localPort,
|
||||
remotePort: 18789,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const SESSIONS_PATCH_WRITE_SCOPE_ENVELOPE_FIELDS: ReadonlySet<string> = new Set(
|
||||
"agentId",
|
||||
"expectedSessionId",
|
||||
"expectedLifecycleRevision",
|
||||
"expectedMarkedUnreadAt",
|
||||
]);
|
||||
|
||||
const SESSIONS_DELETE_WRITE_SCOPE_FIELDS: ReadonlySet<string> = new Set([
|
||||
|
||||
@@ -60,8 +60,12 @@ describe("resolveDynamicSessionMutationRequiredScope", () => {
|
||||
},
|
||||
{
|
||||
name: "CAS envelope",
|
||||
patch: { expectedSessionId: "session-1", expectedLifecycleRevision: "revision-1" },
|
||||
patch: {
|
||||
expectedSessionId: "session-1",
|
||||
expectedLifecycleRevision: "revision-1",
|
||||
},
|
||||
},
|
||||
{ name: "automatic read envelope", patch: { expectedMarkedUnreadAt: 10 } },
|
||||
])("keeps $name write-scoped", ({ patch }) => {
|
||||
expect(
|
||||
resolveDynamicSessionMutationRequiredScope("sessions.patch", {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
SessionsPatchManyParams,
|
||||
SessionsPatchManyResult,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
deleteSession,
|
||||
deleteSessionGroup,
|
||||
deleteSessionsBatch,
|
||||
patchSession,
|
||||
stopCloudWorker,
|
||||
} from "./session-organizer-operations.runtime.ts";
|
||||
|
||||
@@ -45,6 +47,7 @@ function sessionRow(index: number): SidebarRecentSession {
|
||||
function createHarness(
|
||||
params: {
|
||||
methods?: string[] | null;
|
||||
capabilities?: string[];
|
||||
scopes?: string[];
|
||||
current?: boolean;
|
||||
staleAfterRequest?: number;
|
||||
@@ -93,10 +96,16 @@ function createHarness(
|
||||
features:
|
||||
params.methods === null
|
||||
? {}
|
||||
: { methods: params.methods ?? [...SESSION_MUTATION_TEST_METHODS] },
|
||||
: {
|
||||
methods: params.methods ?? [...SESSION_MUTATION_TEST_METHODS],
|
||||
capabilities: params.capabilities ?? [
|
||||
GATEWAY_SERVER_CAPS.SESSION_UNREAD_ACK_CONTRACT,
|
||||
],
|
||||
},
|
||||
auth: { role: "operator", scopes: params.scopes ?? ["operator.write"] },
|
||||
},
|
||||
} as ApplicationGatewaySnapshot;
|
||||
const patch = vi.fn(async (key: string) => ({ ok: true, key }));
|
||||
const refreshReplacement = vi.fn(async () => undefined);
|
||||
const refreshTheme = vi.fn();
|
||||
const deleteMany = vi.fn(
|
||||
@@ -113,6 +122,7 @@ function createHarness(
|
||||
context: { agents: { state: { agentsList: null } }, theme: { refresh: refreshTheme } },
|
||||
gateway: { snapshot },
|
||||
sessions: {
|
||||
patch,
|
||||
refreshReplacement,
|
||||
delete: deleteOne,
|
||||
deleteMany,
|
||||
@@ -140,6 +150,7 @@ function createHarness(
|
||||
deleteOne,
|
||||
groupsDelete,
|
||||
host,
|
||||
patch,
|
||||
pruneSidebarSessionEntry,
|
||||
publishSessionMutationError,
|
||||
refreshReplacement,
|
||||
@@ -162,6 +173,21 @@ function createHarness(
|
||||
}
|
||||
|
||||
describe("patchSessionRows", () => {
|
||||
it("binds Mark as read to the current session identity", async () => {
|
||||
const row = sessionRow(0);
|
||||
const harness = createHarness();
|
||||
|
||||
await expect(patchSession(harness.host, row, { unread: false }, harness.scope)).resolves.toBe(
|
||||
"completed",
|
||||
);
|
||||
|
||||
expect(harness.patch).toHaveBeenCalledWith(
|
||||
row.key,
|
||||
{ unread: false },
|
||||
{ agentId: "main", expectedSessionId: row.sessionId },
|
||||
);
|
||||
});
|
||||
|
||||
it("preflights every lifecycle identity before dispatching the first chunk", async () => {
|
||||
const harness = createHarness();
|
||||
const rows = Array.from({ length: 101 }, (_, index) => sessionRow(index));
|
||||
@@ -222,6 +248,33 @@ describe("patchSessionRows", () => {
|
||||
expect(harness.refreshReplacement).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the legacy-compatible batch Mark as read payload", async () => {
|
||||
const rows = [sessionRow(0), sessionRow(1)];
|
||||
const harness = createHarness();
|
||||
|
||||
await patchSessionRows(harness.host, rows, { unread: false }, harness.scope);
|
||||
|
||||
expect(harness.request).toHaveBeenCalledWith("sessions.patchMany", {
|
||||
targets: rows.map((row) => ({
|
||||
key: row.key,
|
||||
agentId: "main",
|
||||
})),
|
||||
patch: { unread: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the legacy batch read payload when the Gateway lacks the unread contract", async () => {
|
||||
const rows = [sessionRow(0), sessionRow(1)];
|
||||
const harness = createHarness({ capabilities: [] });
|
||||
|
||||
await patchSessionRows(harness.host, rows, { unread: false }, harness.scope);
|
||||
|
||||
expect(harness.request).toHaveBeenCalledWith("sessions.patchMany", {
|
||||
targets: rows.map((row) => ({ key: row.key, agentId: "main" })),
|
||||
patch: { unread: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no requests or refresh when the mutation scope is already stale", async () => {
|
||||
const harness = createHarness({ current: false });
|
||||
|
||||
|
||||
@@ -181,7 +181,8 @@ suite.define(() => {
|
||||
const menuTrigger = activePane.getByRole("button", {
|
||||
name: "Actions for Terminal continuation",
|
||||
});
|
||||
await menuTrigger.press("Enter");
|
||||
await menuTrigger.click();
|
||||
await expect.poll(() => menuTrigger.getAttribute("aria-expanded")).toBe("true");
|
||||
const dropdown = menuTrigger.locator("xpath=ancestor::wa-dropdown");
|
||||
for (const label of compactManagementActions) {
|
||||
await dropdown.getByText(label, { exact: true }).waitFor({ state: "visible" });
|
||||
|
||||
@@ -46,6 +46,7 @@ export function sessionRow(
|
||||
pinnedAt?: number;
|
||||
hasActiveRun?: boolean;
|
||||
unread?: boolean;
|
||||
markedUnreadAt?: number;
|
||||
status?: string;
|
||||
spawnedBy?: string;
|
||||
startedAt?: number;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import path from "node:path";
|
||||
import { GATEWAY_SERVER_CAPS } from "@openclaw/gateway-protocol";
|
||||
import { expect, it } from "vitest";
|
||||
import { expectRequestCountStable } from "./chat-flow.test-support.ts";
|
||||
import {
|
||||
captureUiProof,
|
||||
captureUiProofEnabled,
|
||||
controlUiSessionPath,
|
||||
controlUiSessionUrl,
|
||||
createSessionManagementE2eSuite,
|
||||
installMockGateway,
|
||||
requireRecord,
|
||||
sessionRow,
|
||||
sessionsListResponse,
|
||||
uiProofArtifactDir,
|
||||
waitForPatch,
|
||||
} from "./session-management.test-support.ts";
|
||||
|
||||
const suite = createSessionManagementE2eSuite();
|
||||
|
||||
suite.define(() => {
|
||||
it("preserves manually unread state through active run updates until the session is reopened", async () => {
|
||||
const activeKey = "agent:main:active";
|
||||
const otherKey = "agent:main:other";
|
||||
const context = await suite.browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
recordVideo: captureUiProofEnabled
|
||||
? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } }
|
||||
: undefined,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const proofVideo = page.video();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureCapabilities: [GATEWAY_SERVER_CAPS.SESSION_UNREAD_ACK_CONTRACT],
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow(activeKey, "Active investigation", 20, { unread: false }),
|
||||
sessionRow(otherKey, "Other thread", 10, { unread: false }),
|
||||
]),
|
||||
"sessions.patch": {},
|
||||
},
|
||||
sessionKey: activeKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, activeKey));
|
||||
const activeRow = page.locator(`[data-session-key="${activeKey}"]`);
|
||||
const otherRow = page.locator(`[data-session-key="${otherKey}"]`);
|
||||
await activeRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await otherRow.waitFor({ state: "visible" });
|
||||
await captureUiProof(page, "manual-unread-before.png");
|
||||
|
||||
await activeRow.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Mark as unread" }).click();
|
||||
const markUnread = await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === activeKey && params.unread === true,
|
||||
);
|
||||
expect(requireRecord(markUnread.params)).not.toHaveProperty("expectedMarkedUnreadAt");
|
||||
|
||||
await activeRow.locator(".session-unread-dot").waitFor();
|
||||
await expectRequestCountStable(gateway, "sessions.patch", 1);
|
||||
await captureUiProof(page, "manual-unread-marked.png");
|
||||
|
||||
const marker = 1_800_000_000_001;
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
reason: "run",
|
||||
sessionKey: activeKey,
|
||||
session: {
|
||||
...sessionRow(activeKey, "Active investigation", 30, {
|
||||
hasActiveRun: true,
|
||||
status: "running",
|
||||
unread: true,
|
||||
}),
|
||||
markedUnreadAt: marker,
|
||||
},
|
||||
});
|
||||
await activeRow.locator(".session-run-spinner").waitFor();
|
||||
await expectRequestCountStable(gateway, "sessions.patch", 1);
|
||||
await captureUiProof(page, "manual-unread-running.png");
|
||||
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
reason: "run",
|
||||
sessionKey: activeKey,
|
||||
session: {
|
||||
...sessionRow(activeKey, "Active investigation", 40, {
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
unread: true,
|
||||
}),
|
||||
markedUnreadAt: marker,
|
||||
},
|
||||
});
|
||||
await activeRow.locator(".session-unread-dot").waitFor();
|
||||
await expectRequestCountStable(gateway, "sessions.patch", 1);
|
||||
await captureUiProof(page, "manual-unread-complete.png");
|
||||
|
||||
await otherRow.getByRole("link").click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(otherKey));
|
||||
await activeRow.getByRole("link").click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(activeKey));
|
||||
|
||||
const acknowledge = await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === activeKey && params.unread === false,
|
||||
);
|
||||
expect(requireRecord(acknowledge.params)).toMatchObject({
|
||||
expectedMarkedUnreadAt: marker,
|
||||
key: activeKey,
|
||||
unread: false,
|
||||
});
|
||||
expect(requireRecord(acknowledge.params)).not.toHaveProperty("readIntent");
|
||||
await captureUiProof(page, "manual-unread-reopened.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
if (proofVideo) {
|
||||
await proofVideo.saveAs(path.join(uiProofArtifactDir, "manual-unread-running.webm"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ export type SessionPatchOptions = {
|
||||
agentId?: string;
|
||||
/** Durable identity observed with the row before an archive or restore action. */
|
||||
expectedSessionId?: string;
|
||||
/** Explicit unread marker observed by an automatic read acknowledgement. */
|
||||
expectedMarkedUnreadAt?: number | null;
|
||||
/** Let a caller with stricter lifecycle ownership publish the resolved model value. */
|
||||
deferModelOverride?: boolean;
|
||||
/** Keep optimistic model state bound to the UI owner that initiated the patch. */
|
||||
|
||||
@@ -142,12 +142,19 @@ export function requestSessionPatch(
|
||||
client: SessionRequestClient,
|
||||
key: string,
|
||||
patch: SessionPatch,
|
||||
options: { agentId?: string | null; expectedSessionId?: string | null } = {},
|
||||
options: {
|
||||
agentId?: string | null;
|
||||
expectedSessionId?: string | null;
|
||||
expectedMarkedUnreadAt?: number | null;
|
||||
} = {},
|
||||
): Promise<SessionsPatchResult> {
|
||||
const expectedSessionId = options.expectedSessionId?.trim();
|
||||
const params = {
|
||||
...buildSessionRequestParams(key, options.agentId),
|
||||
...(expectedSessionId ? { expectedSessionId } : {}),
|
||||
...(options.expectedMarkedUnreadAt !== undefined
|
||||
? { expectedMarkedUnreadAt: options.expectedMarkedUnreadAt }
|
||||
: {}),
|
||||
...patch,
|
||||
};
|
||||
return patch.archived === true
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { createSessionCapability } from "./index.ts";
|
||||
import { createGatewayHarness } from "./session-capability.test-support.ts";
|
||||
|
||||
const key = "agent:main:unread-contract";
|
||||
|
||||
describe("session unread mutation capability", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "automatic acknowledgement",
|
||||
options: { expectedMarkedUnreadAt: 42 },
|
||||
expected: { expectedMarkedUnreadAt: 42 },
|
||||
},
|
||||
{
|
||||
name: "explicit read",
|
||||
options: {},
|
||||
expected: {},
|
||||
},
|
||||
])("sends the current payload for $name", async ({ expected, options }) => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
return { ok: true, path: "", key, entry: {} };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { gateway } = createGatewayHarness(client, ["sessions.patch"]);
|
||||
const sessions = createSessionCapability(gateway);
|
||||
|
||||
await sessions.patch(key, { unread: false }, { ...options, deferListRefresh: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key,
|
||||
unread: false,
|
||||
...expected,
|
||||
});
|
||||
sessions.dispose();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,12 @@ describe("SessionUnreadPatchGuard", () => {
|
||||
expect(guard.shouldPatch("agent:main:a", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a null marker as no manual marker", () => {
|
||||
const guard = new SessionUnreadPatchGuard();
|
||||
expect(guard.shouldPatch("agent:main:a", false)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:a", true, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not patch read sessions and resets after changing sessions", () => {
|
||||
const guard = new SessionUnreadPatchGuard();
|
||||
expect(guard.shouldPatch("agent:main:a", false)).toBe(false);
|
||||
@@ -36,4 +42,27 @@ describe("SessionUnreadPatchGuard", () => {
|
||||
expect(guard.shouldPatch("agent:main:b", true)).toBe(true);
|
||||
expect(guard.shouldPatch("agent:main:a", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a manual unread marker created after the active session was observed", () => {
|
||||
const guard = new SessionUnreadPatchGuard();
|
||||
expect(guard.shouldPatch("agent:main:a", false)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:a", true, 100)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:a", true, 100)).toBe(false);
|
||||
});
|
||||
|
||||
it("acknowledges a manual unread marker on a later activation", () => {
|
||||
const guard = new SessionUnreadPatchGuard();
|
||||
expect(guard.shouldPatch("agent:main:a", false)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:a", true, 100)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:b", false)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:a", true, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("restarts the unread episode when a retained pane is presented again", () => {
|
||||
const guard = new SessionUnreadPatchGuard();
|
||||
expect(guard.shouldPatch("agent:main:a", false)).toBe(false);
|
||||
expect(guard.shouldPatch("agent:main:a", true, 100)).toBe(false);
|
||||
guard.beginActivation("agent:main:a");
|
||||
expect(guard.shouldPatch("agent:main:a", true, 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,21 +5,42 @@
|
||||
*/
|
||||
export class SessionUnreadPatchGuard {
|
||||
private activeSessionKey = "";
|
||||
private activationObserved = false;
|
||||
private activationMarkedUnreadAt: number | undefined;
|
||||
private requested = false;
|
||||
|
||||
shouldPatch(activeSessionKey: string, unread: boolean | undefined): boolean {
|
||||
beginActivation(activeSessionKey: string) {
|
||||
this.activeSessionKey = activeSessionKey.trim();
|
||||
this.activationObserved = false;
|
||||
this.activationMarkedUnreadAt = undefined;
|
||||
this.requested = false;
|
||||
}
|
||||
|
||||
shouldPatch(
|
||||
activeSessionKey: string,
|
||||
unread: boolean | undefined,
|
||||
markedUnreadAt?: number | null,
|
||||
): boolean {
|
||||
const key = activeSessionKey.trim();
|
||||
const marker = markedUnreadAt ?? undefined;
|
||||
if (key !== this.activeSessionKey) {
|
||||
this.activeSessionKey = key;
|
||||
this.requested = false;
|
||||
this.beginActivation(key);
|
||||
}
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
if (!this.activationObserved) {
|
||||
this.activationObserved = true;
|
||||
this.activationMarkedUnreadAt = marker;
|
||||
}
|
||||
if (unread === false) {
|
||||
this.activationMarkedUnreadAt = undefined;
|
||||
this.requested = false;
|
||||
return false;
|
||||
}
|
||||
if (marker !== undefined && marker !== this.activationMarkedUnreadAt) {
|
||||
return false;
|
||||
}
|
||||
if (unread !== true || this.requested) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -371,7 +371,10 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
||||
deferBranches: true,
|
||||
historyLoad: resumedHistory,
|
||||
});
|
||||
this.deferSessionHydrationUntilTranscript(state.sessionKey, historyRefresh);
|
||||
this.deferSessionHydrationUntilTranscript(
|
||||
state.sessionKey,
|
||||
historyRefresh.then(() => getChatHistoryLoadState(state).phase === "committed"),
|
||||
);
|
||||
}
|
||||
const routeSessionKey = this.sessionKey.trim();
|
||||
const catalogRouteKey = parseCatalogSessionKey(routeSessionKey);
|
||||
@@ -460,7 +463,10 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
||||
deferBranches: true,
|
||||
historyLoad: resumedHistory,
|
||||
});
|
||||
this.deferSessionHydrationUntilTranscript(startupSessionKey, historyRefresh);
|
||||
this.deferSessionHydrationUntilTranscript(
|
||||
startupSessionKey,
|
||||
historyRefresh.then(() => getChatHistoryLoadState(state).phase === "committed"),
|
||||
);
|
||||
void historyRefresh.finally(() => {
|
||||
void finishStartup();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { retryReconnectableQueuedChatSends } from "./chat-send-actions.ts";
|
||||
import { setChatError } from "./chat-send-queue-state.ts";
|
||||
import { refreshCurrentChatSessionList } from "./chat-session.ts";
|
||||
import { invalidateImageLightbox } from "./chat-state-page.ts";
|
||||
import { selectedChatSessionRow } from "./chat-state-route.ts";
|
||||
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
|
||||
import { resetTaskDetail } from "./components/chat-task-detail-state.ts";
|
||||
import { resetTranscriptSession } from "./components/chat-thread-interactions.ts";
|
||||
@@ -49,7 +50,13 @@ export abstract class ChatPaneRetainedPresentation extends ChatPaneBoard {
|
||||
this.consumeSessionHandoff(this.sessionKey);
|
||||
this.syncActiveBindings();
|
||||
const state = this.state;
|
||||
if (state) {
|
||||
this.unreadPatchGuard.beginActivation(state.sessionKey);
|
||||
}
|
||||
const deferredHydrationActive = this.resumeDeferredSessionHydration();
|
||||
if (state && !deferredHydrationActive) {
|
||||
this.markSessionRead(selectedChatSessionRow(state));
|
||||
}
|
||||
if (
|
||||
state &&
|
||||
!deferredHydrationActive &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../../lib/session-pull-requests.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
|
||||
import { createTestChatPane } from "./chat-pane.test-support.ts";
|
||||
import type { AfterCommitEffect, RenderLifecycle } from "./render-lifecycle.ts";
|
||||
|
||||
@@ -19,9 +20,11 @@ function createSecondaryHydrationPane() {
|
||||
const secondaryResponse = new Promise<never>(() => {});
|
||||
const request = vi.fn((_method: string, _params?: unknown) => secondaryResponse);
|
||||
const listBranches = vi.fn(() => secondaryResponse);
|
||||
const patch = vi.fn().mockResolvedValue({});
|
||||
const sessions = {
|
||||
capturePullRequestEpoch: vi.fn(() => ({})),
|
||||
listBranches,
|
||||
patch,
|
||||
setPullRequestSummary: vi.fn(),
|
||||
} as unknown as SessionCapability;
|
||||
const { pane, state } = createTestChatPane({
|
||||
@@ -30,31 +33,31 @@ function createSecondaryHydrationPane() {
|
||||
});
|
||||
state.assistantAgentId = "main";
|
||||
state.sessionKey = "agent:work:current";
|
||||
pane.context.gateway.snapshot.hello = {
|
||||
features: {
|
||||
methods: [SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, "session.discussion.info"],
|
||||
},
|
||||
} as never;
|
||||
pane.context.gateway.snapshot.hello = gatewayHelloForMethods([
|
||||
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
"session.discussion.info",
|
||||
"sessions.patch",
|
||||
]);
|
||||
const commitEffects: AfterCommitEffect[] = [];
|
||||
const afterCommit = vi.fn((effect: AfterCommitEffect) => {
|
||||
commitEffects.push(effect);
|
||||
return () => undefined;
|
||||
});
|
||||
state.renderLifecycle = { invalidate: vi.fn(), afterCommit } satisfies RenderLifecycle;
|
||||
return { afterCommit, commitEffects, listBranches, pane, request, state };
|
||||
return { afterCommit, commitEffects, listBranches, pane, patch, request, state };
|
||||
}
|
||||
|
||||
describe("chat pane session hydration", () => {
|
||||
it("starts secondary RPCs together only after the transcript commit", async () => {
|
||||
const { afterCommit, commitEffects, listBranches, pane, request, state } =
|
||||
createSecondaryHydrationPane();
|
||||
const transcript = deferred<void>();
|
||||
const transcript = deferred<boolean>();
|
||||
|
||||
pane.deferSessionHydrationUntilTranscript(state.sessionKey, transcript.promise);
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(listBranches).not.toHaveBeenCalled();
|
||||
transcript.resolve();
|
||||
transcript.resolve(true);
|
||||
await transcript.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -86,19 +89,19 @@ describe("chat pane session hydration", () => {
|
||||
});
|
||||
const afterCommit = vi.fn<RenderLifecycle["afterCommit"]>(() => () => undefined);
|
||||
state.renderLifecycle = { invalidate: vi.fn(), afterCommit };
|
||||
const previousTranscript = deferred<void>();
|
||||
const currentTranscript = deferred<void>();
|
||||
const previousTranscript = deferred<boolean>();
|
||||
const currentTranscript = deferred<boolean>();
|
||||
|
||||
pane.deferSessionHydrationUntilTranscript(state.sessionKey, previousTranscript.promise);
|
||||
state.sessionKey = "agent:main:current-2";
|
||||
pane.deferSessionHydrationUntilTranscript(state.sessionKey, currentTranscript.promise);
|
||||
|
||||
previousTranscript.resolve();
|
||||
previousTranscript.resolve(true);
|
||||
await previousTranscript.promise;
|
||||
await Promise.resolve();
|
||||
expect(afterCommit).not.toHaveBeenCalled();
|
||||
|
||||
currentTranscript.resolve();
|
||||
currentTranscript.resolve(true);
|
||||
await currentTranscript.promise;
|
||||
await Promise.resolve();
|
||||
expect(afterCommit).toHaveBeenCalledOnce();
|
||||
@@ -106,11 +109,11 @@ describe("chat pane session hydration", () => {
|
||||
|
||||
it("resumes deferred companion and discussion hydration when a retained pane returns", async () => {
|
||||
const { commitEffects, pane, request, state } = createSecondaryHydrationPane();
|
||||
const transcript = deferred<void>();
|
||||
const transcript = deferred<boolean>();
|
||||
|
||||
pane.deferSessionHydrationUntilTranscript(state.sessionKey, transcript.promise);
|
||||
pane.presented = false;
|
||||
transcript.resolve();
|
||||
transcript.resolve(true);
|
||||
await transcript.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -126,4 +129,39 @@ describe("chat pane session hydration", () => {
|
||||
expect(methods).toContain("session.discussion.info");
|
||||
expect(methods).toContain("sessions.companion.state");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "commits", committed: true, expectedPatches: 1 },
|
||||
{ name: "fails", committed: false, expectedPatches: 0 },
|
||||
])(
|
||||
"acknowledges unread only after deferred transcript hydration $name",
|
||||
async ({ committed, expectedPatches }) => {
|
||||
const { commitEffects, pane, patch, state } = createSecondaryHydrationPane();
|
||||
const transcript = deferred<boolean>();
|
||||
state.sessionsResult = {
|
||||
sessions: [
|
||||
{
|
||||
key: state.sessionKey,
|
||||
kind: "direct",
|
||||
updatedAt: 20,
|
||||
unread: true,
|
||||
},
|
||||
],
|
||||
} as never;
|
||||
|
||||
pane.presented = false;
|
||||
pane.deferSessionHydrationUntilTranscript(state.sessionKey, transcript.promise);
|
||||
pane.presented = true;
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
|
||||
transcript.resolve(committed);
|
||||
await transcript.promise;
|
||||
await Promise.resolve();
|
||||
expect(commitEffects).toHaveLength(1);
|
||||
|
||||
commitEffects[0]!(vi.fn());
|
||||
await Promise.resolve();
|
||||
expect(patch).toHaveBeenCalledTimes(expectedPatches);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
} from "./chat-pane-shared.ts";
|
||||
import { ChatPaneTaskSuggestions } from "./chat-pane-task-suggestions.ts";
|
||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||
import { resolveChatAgentId, saveRouteSessionSettings } from "./chat-state-route.ts";
|
||||
import {
|
||||
resolveChatAgentId,
|
||||
saveRouteSessionSettings,
|
||||
selectedChatSessionRow,
|
||||
} from "./chat-state-route.ts";
|
||||
import {
|
||||
dismissChatPullRequest,
|
||||
listDismissedChatPullRequests,
|
||||
@@ -171,7 +175,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
||||
|
||||
protected deferSessionHydrationUntilTranscript(
|
||||
sessionKey: string,
|
||||
transcriptLoad: Promise<unknown>,
|
||||
transcriptLoad: Promise<boolean>,
|
||||
): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
@@ -195,13 +199,13 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
||||
state.connected &&
|
||||
state.client === client &&
|
||||
state.sessionKey === sessionKey;
|
||||
const scheduleHydration = () => {
|
||||
const scheduleHydration = (historyCommitted: boolean) => {
|
||||
if (!isCurrent()) {
|
||||
retireIfCurrent();
|
||||
return;
|
||||
}
|
||||
if (!this.presented) {
|
||||
this.pendingDeferredSessionHydration = scheduleHydration;
|
||||
this.pendingDeferredSessionHydration = () => scheduleHydration(historyCommitted);
|
||||
return;
|
||||
}
|
||||
this.pendingDeferredSessionHydration = null;
|
||||
@@ -210,19 +214,22 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
||||
state.renderLifecycle.afterCommit((complete) => {
|
||||
if (isCurrent() && this.presented) {
|
||||
this.deferredSessionHydrationActive = false;
|
||||
if (historyCommitted) {
|
||||
this.markSessionRead(selectedChatSessionRow(state));
|
||||
}
|
||||
void loadChatBranches(state);
|
||||
void this.probeSessionDiscussion(sessionKey);
|
||||
this.hydrateSessionCompanion(sessionKey);
|
||||
void this.refreshSessionPullRequests();
|
||||
} else if (isCurrent()) {
|
||||
this.pendingDeferredSessionHydration = scheduleHydration;
|
||||
this.pendingDeferredSessionHydration = () => scheduleHydration(historyCommitted);
|
||||
} else {
|
||||
retireIfCurrent();
|
||||
}
|
||||
complete();
|
||||
});
|
||||
};
|
||||
void transcriptLoad.then(scheduleHydration, scheduleHydration);
|
||||
void transcriptLoad.then(scheduleHydration, () => scheduleHydration(false));
|
||||
}
|
||||
|
||||
protected resumeDeferredSessionHydration(): boolean {
|
||||
@@ -250,7 +257,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
||||
const agentStatusActive = Boolean(row.agentStatus && row.agentStatus.expiresAt > Date.now());
|
||||
const unread = row.unread === true || unreadFailure || agentStatusActive;
|
||||
if (!unread) {
|
||||
this.unreadPatchGuard.shouldPatch(state.sessionKey, false);
|
||||
this.unreadPatchGuard.shouldPatch(state.sessionKey, false, row.markedUnreadAt);
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(row.key)?.agentId ?? resolveChatAgentId(state);
|
||||
@@ -260,24 +267,33 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
||||
});
|
||||
// Read-only navigation must remain silent: absence of mutation access is
|
||||
// not an operation failure and should not latch the unread retry guard.
|
||||
if (!access.allowed || !this.unreadPatchGuard.shouldPatch(state.sessionKey, true)) {
|
||||
if (
|
||||
!access.allowed ||
|
||||
!this.unreadPatchGuard.shouldPatch(state.sessionKey, true, row.markedUnreadAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const guardKey = state.sessionKey;
|
||||
void this.context.sessions.patch(row.key, { unread: false }, { agentId }).then(
|
||||
(result) => {
|
||||
// A null result means no request was sent (connection scope lost);
|
||||
// unlatch like a failure or the badge stays lit until navigation.
|
||||
if (result === null) {
|
||||
void this.context.sessions
|
||||
.patch(
|
||||
row.key,
|
||||
{ unread: false },
|
||||
{ agentId, expectedMarkedUnreadAt: row.markedUnreadAt ?? null },
|
||||
)
|
||||
.then(
|
||||
(result) => {
|
||||
// A null result means no request was sent (connection scope lost);
|
||||
// unlatch like a failure or the badge stays lit until navigation.
|
||||
if (result === null) {
|
||||
this.unreadPatchGuard.patchFailed(guardKey);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Unlatch so later unread snapshots retry; the session capability
|
||||
// publishes the actionable error for the owning page.
|
||||
this.unreadPatchGuard.patchFailed(guardKey);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Unlatch so later unread snapshots retry; the session capability
|
||||
// publishes the actionable error for the owning page.
|
||||
this.unreadPatchGuard.patchFailed(guardKey);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected async restoreArchivedSession(sessionKey: string, expectedSessionId: string) {
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("chat pane read markers", () => {
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"agent:main:current",
|
||||
{ unread: false },
|
||||
{ agentId: "main" },
|
||||
{ agentId: "main", expectedMarkedUnreadAt: null },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("chat pane read markers", () => {
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"agent:main:current",
|
||||
{ unread: false },
|
||||
{ agentId: "main" },
|
||||
{ agentId: "main", expectedMarkedUnreadAt: null },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -150,7 +150,66 @@ describe("chat pane read markers", () => {
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"agent:main:current",
|
||||
{ unread: false },
|
||||
{ agentId: "main" },
|
||||
{ agentId: "main", expectedMarkedUnreadAt: null },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a manual unread marker received after activation", () => {
|
||||
const patch = vi.fn().mockResolvedValue(null);
|
||||
const { pane } = createTestChatPane({
|
||||
client: {} as GatewayBrowserClient,
|
||||
sessions: { patch } as unknown as SessionCapability,
|
||||
});
|
||||
|
||||
pane.markSessionRead({
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
updatedAt: 10,
|
||||
unread: false,
|
||||
});
|
||||
pane.markSessionRead({
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
markedUnreadAt: 20,
|
||||
updatedAt: 20,
|
||||
unread: true,
|
||||
});
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acknowledges a manual unread marker when a retained pane is presented again", () => {
|
||||
const patch = vi.fn().mockResolvedValue({});
|
||||
const { pane } = createTestChatPane({
|
||||
client: {} as GatewayBrowserClient,
|
||||
sessions: { patch } as unknown as SessionCapability,
|
||||
});
|
||||
const row = {
|
||||
key: "agent:main:current",
|
||||
kind: "direct" as const,
|
||||
markedUnreadAt: 20,
|
||||
updatedAt: 20,
|
||||
unread: true,
|
||||
};
|
||||
|
||||
pane.markSessionRead({ ...row, markedUnreadAt: undefined, unread: false });
|
||||
pane.markSessionRead(row);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
|
||||
pane.presented = false;
|
||||
pane.applySessionsState({
|
||||
result: { sessions: [row] },
|
||||
agentId: "main",
|
||||
loading: false,
|
||||
error: null,
|
||||
deletedSessions: [],
|
||||
} as unknown as Parameters<typeof pane.applySessionsState>[0]);
|
||||
pane.presented = true;
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"agent:main:current",
|
||||
{ unread: false },
|
||||
{ agentId: "main", expectedMarkedUnreadAt: 20 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ export type TestChatPane = HTMLElement & {
|
||||
performUpdate: () => void;
|
||||
deferSessionHydrationUntilTranscript: (
|
||||
sessionKey: string,
|
||||
transcriptLoad: Promise<unknown>,
|
||||
transcriptLoad: Promise<boolean>,
|
||||
) => void;
|
||||
paneTitle: string;
|
||||
catalogSession: SessionCatalogSession | null;
|
||||
|
||||
@@ -53,7 +53,7 @@ export type TestSessionsPage = HTMLElement & {
|
||||
) => void;
|
||||
patchSession: (
|
||||
key: string,
|
||||
patch: { archived?: boolean; pinned?: boolean; label?: string | null },
|
||||
patch: { archived?: boolean; pinned?: boolean; label?: string | null; unread?: boolean },
|
||||
scope?: unknown,
|
||||
expectedSessionId?: string,
|
||||
) => Promise<unknown>;
|
||||
|
||||
@@ -300,6 +300,25 @@ describe("sessions page lifecycle", () => {
|
||||
expect(page.error).toBe("Connect to the Gateway to change sessions.");
|
||||
});
|
||||
|
||||
it("uses the legacy-compatible Mark as read payload", async () => {
|
||||
const patch = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
path: "",
|
||||
key: "agent:main:main",
|
||||
entry: { sessionId: "session-main" },
|
||||
}));
|
||||
const sessions = createSessions({ patch });
|
||||
const page = await createPage(
|
||||
createContext(createGateway({} as GatewayBrowserClient).gateway, sessions),
|
||||
);
|
||||
|
||||
await expect(page.patchSession("agent:main:main", { unread: false })).resolves.toBe(
|
||||
"completed",
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("agent:main:main", { unread: false }, { agentId: "main" });
|
||||
});
|
||||
|
||||
it("shows a connection error in the checkpoints drawer while disconnected", async () => {
|
||||
const mutableGateway = createGateway({} as GatewayBrowserClient);
|
||||
const page = await createPage(createContext(mutableGateway.gateway, createSessions()));
|
||||
|
||||
@@ -1082,7 +1082,11 @@ class SessionsPage extends OpenClawLightDomElement {
|
||||
if (
|
||||
!this.requireMutationAccess(scope, {
|
||||
method: "sessions.patch",
|
||||
params: { key, ...patch, ...(agentId ? { agentId } : {}) },
|
||||
params: {
|
||||
key,
|
||||
...patch,
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
})
|
||||
) {
|
||||
return "failed";
|
||||
|
||||
@@ -1059,6 +1059,7 @@ function installControlUiMockGateway(
|
||||
const requests: BrowserRequest[] = [];
|
||||
const methodResponseSequenceIndexes = new Map<string, number>();
|
||||
const sessionPatches = new Map<string, Record<string, unknown>>();
|
||||
let sessionPatchTimestamp = 1_800_000_000_000;
|
||||
const createdSessions = new Map<string, Record<string, unknown>>();
|
||||
const terminalSessions = new Map<string, MockTerminalSession>();
|
||||
let terminalSessionSequence = 0;
|
||||
@@ -1390,6 +1391,14 @@ function installControlUiMockGateway(
|
||||
patch[key] = params[key];
|
||||
}
|
||||
}
|
||||
if (params.unread === true) {
|
||||
sessionPatchTimestamp += 1;
|
||||
patch.markedUnreadAt = sessionPatchTimestamp;
|
||||
} else if (params.unread === false) {
|
||||
sessionPatchTimestamp += 1;
|
||||
patch.lastReadAt = sessionPatchTimestamp;
|
||||
patch.markedUnreadAt = undefined;
|
||||
}
|
||||
if (scenario.sessionArchiveFiltering && hasOwn(params, "archived")) {
|
||||
patch.archived = params.archived;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user