Skip to content

Commit d31eeea

Browse files
authored
fix(android): upsert device sessions safely (#105144)
1 parent 514d718 commit d31eeea

2 files changed

Lines changed: 42 additions & 48 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -318,17 +318,9 @@ class ChatController internal constructor(
318318
.asStringOrNull()
319319
?.trim()
320320
?.takeIf { it.isNotEmpty() }
321-
if (existingSession == null) {
322-
val createParams =
323-
buildJsonObject {
324-
put("key", JsonPrimitive(mainSession.key))
325-
put("agentId", JsonPrimitive(resolveAgentIdForSessionKey(mainSession.key)))
326-
put("label", JsonPrimitive(mainSession.label))
327-
}
328-
requestGatewayBound(requestScope.gatewayId, "sessions.create", createParams.toString())
329-
} else if (existingLabel == null) {
330-
// An existing unlabeled session owns upgrade history already. Patch metadata instead
331-
// of replaying create lifecycle behavior against that live session.
321+
if (existingLabel == null) {
322+
// Label-only sessions.patch is operator.write-scoped and atomically upserts the row,
323+
// avoiding the concurrent-session identity race in sessions.create.
332324
val patchParams =
333325
buildJsonObject {
334326
put("key", JsonPrimitive(mainSession.key))

apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ class ChatControllerReconnectRestoreTest {
4040

4141
@Test
4242
@OptIn(ExperimentalCoroutinesApi::class)
43-
fun connectedRefreshCreatesDeviceSessionBeforeLoadingHistory() =
43+
fun connectedRefreshUpsertsDeviceSessionBeforeLoadingHistory() =
4444
runTest {
4545
val sessionKey = "agent:main:node-device"
4646
val gateway = ScriptedGateway(json)
4747
gateway.respondWith("sessions.describe", """{"session":null}""")
48-
gateway.respondWith("sessions.create", """{"ok":true,"key":"$sessionKey"}""")
48+
gateway.respondWith("sessions.patch", """{"ok":true,"key":"$sessionKey"}""")
4949
gateway.respondWith("chat.history", historyResponse("session-1", emptyList()))
5050
val controller = newScopedController(gateway)
5151

@@ -57,16 +57,15 @@ class ChatControllerReconnectRestoreTest {
5757
runCurrent()
5858

5959
val describeIndex = gateway.calls.indexOfFirst { it.method == "sessions.describe" }
60-
val createIndex = gateway.calls.indexOfFirst { it.method == "sessions.create" }
60+
val patchIndex = gateway.calls.indexOfFirst { it.method == "sessions.patch" }
6161
val historyIndex = gateway.calls.indexOfFirst { it.method == "chat.history" }
6262
assertTrue(describeIndex >= 0)
63-
assertTrue(createIndex > describeIndex)
64-
assertTrue(historyIndex > createIndex)
63+
assertTrue(patchIndex > describeIndex)
64+
assertTrue(historyIndex > patchIndex)
6565
assertEquals(sessionKey, controller.sessionKey.value)
66-
val createParams = json.parseToJsonElement(gateway.calls[createIndex].paramsJson.orEmpty()).jsonObject
67-
assertEquals(sessionKey, createParams["key"]?.jsonPrimitive?.content)
68-
assertEquals("main", createParams["agentId"]?.jsonPrimitive?.content)
69-
assertEquals("OpenClaw App · Pixel · device", createParams["label"]?.jsonPrimitive?.content)
66+
val patchParams = json.parseToJsonElement(gateway.calls[patchIndex].paramsJson.orEmpty()).jsonObject
67+
assertEquals(sessionKey, patchParams["key"]?.jsonPrimitive?.content)
68+
assertEquals("OpenClaw App · Pixel · device", patchParams["label"]?.jsonPrimitive?.content)
7069
}
7170

7271
@Test
@@ -76,15 +75,15 @@ class ChatControllerReconnectRestoreTest {
7675
val sessionKey = "agent:main:node-device"
7776
val gateway = ScriptedGateway(json)
7877
gateway.respondWith("sessions.describe", """{"session":null}""")
79-
gateway.respond("sessions.create") { error("create unavailable") }
78+
gateway.respond("sessions.patch") { error("patch unavailable") }
8079
gateway.respondWith("chat.history", historyResponse("session-1", emptyList()))
8180
val controller = newScopedController(gateway)
8281

8382
controller.prepareMainSessionKey(sessionKey)
8483
controller.onGatewayConnected(MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device"))
8584
runCurrent()
8685

87-
assertEquals(1, gateway.callCount("sessions.create"))
86+
assertEquals(1, gateway.callCount("sessions.patch"))
8887
assertEquals(1, gateway.callCount("chat.history"))
8988
assertEquals(sessionKey, controller.sessionKey.value)
9089
assertNull(controller.errorText.value)
@@ -158,7 +157,7 @@ class ChatControllerReconnectRestoreTest {
158157
storedLabel?.let { """{"session":{"key":"$sessionKey","label":"$it"}}""" }
159158
?: """{"session":null}"""
160159
}
161-
gateway.respond("sessions.create") { paramsJson ->
160+
gateway.respond("sessions.patch") { paramsJson ->
162161
storedLabel =
163162
json
164163
.parseToJsonElement(paramsJson.orEmpty())
@@ -178,15 +177,15 @@ class ChatControllerReconnectRestoreTest {
178177
controller.onGatewayConnected(binding)
179178
runCurrent()
180179

181-
assertEquals(1, gateway.callCount("sessions.create"))
180+
assertEquals(1, gateway.callCount("sessions.patch"))
182181
assertEquals(2, gateway.callCount("sessions.describe"))
183182
assertEquals(2, gateway.callCount("chat.history"))
184183

185184
storedLabel = "My Android session"
186185
controller.onGatewayConnected(binding.copy(label = "OpenClaw App · Renamed · device"))
187186
runCurrent()
188187

189-
assertEquals(1, gateway.callCount("sessions.create"))
188+
assertEquals(1, gateway.callCount("sessions.patch"))
190189
assertEquals(3, gateway.callCount("sessions.describe"))
191190
assertEquals(3, gateway.callCount("chat.history"))
192191
assertEquals("My Android session", storedLabel)
@@ -207,7 +206,7 @@ class ChatControllerReconnectRestoreTest {
207206
?.content
208207
if (key == "agent:first:node-device") firstDescribe.await() else """{"session":null}"""
209208
}
210-
gateway.respond("sessions.create") { paramsJson ->
209+
gateway.respond("sessions.patch") { paramsJson ->
211210
val key =
212211
json
213212
.parseToJsonElement(paramsJson.orEmpty())
@@ -227,29 +226,32 @@ class ChatControllerReconnectRestoreTest {
227226
controller.refresh()
228227
runCurrent()
229228

230-
val createIndexBeforeFirstDescribeCompletes =
231-
gateway.calls.indexOfLast { it.method == "sessions.create" }
232-
val historyCallsBeforeFirstDescribeCompletes =
233-
gateway.calls.withIndex().filter { it.value.method == "chat.history" }
234-
assertTrue(createIndexBeforeFirstDescribeCompletes >= 0)
235-
assertTrue(historyCallsBeforeFirstDescribeCompletes.isNotEmpty())
236-
assertTrue(historyCallsBeforeFirstDescribeCompletes.all { it.index > createIndexBeforeFirstDescribeCompletes })
237-
assertTrue(
238-
historyCallsBeforeFirstDescribeCompletes.all {
239-
gateway.sessionKeyOf(it.value.paramsJson) == "agent:second:node-device"
240-
},
241-
)
242-
firstDescribe.complete("""{"session":null}""")
243-
runCurrent()
244-
245-
val createIndex = gateway.calls.indexOfLast { it.method == "sessions.create" }
229+
val patchCalls = gateway.calls.withIndex().filter { it.value.method == "sessions.patch" }
230+
val patchIndex = patchCalls.single().index
246231
val historyCalls = gateway.calls.withIndex().filter { it.value.method == "chat.history" }
247-
assertEquals(1, gateway.callCount("sessions.create"))
248-
assertTrue(createIndex >= 0)
232+
val patchParams =
233+
patchCalls
234+
.single()
235+
.value
236+
.paramsJson
237+
.orEmpty()
238+
val patchedKey =
239+
json
240+
.parseToJsonElement(patchParams)
241+
.jsonObject["key"]
242+
?.jsonPrimitive
243+
?.content
244+
assertEquals("agent:second:node-device", patchedKey)
249245
assertTrue(historyCalls.isNotEmpty())
250-
assertTrue(historyCalls.all { it.index > createIndex })
246+
assertTrue(historyCalls.all { it.index > patchIndex })
251247
assertTrue(historyCalls.all { gateway.sessionKeyOf(it.value.paramsJson) == "agent:second:node-device" })
252248
assertEquals("agent:second:node-device", controller.sessionKey.value)
249+
250+
// The cancelled response must remain inert even if its server-side work completes later.
251+
firstDescribe.complete("""{"session":null}""")
252+
runCurrent()
253+
assertEquals(1, gateway.callCount("sessions.patch"))
254+
assertTrue(gateway.calls.none { it.method == "chat.history" && gateway.sessionKeyOf(it.paramsJson) == "agent:first:node-device" })
253255
}
254256

255257
@Test
@@ -325,7 +327,7 @@ class ChatControllerReconnectRestoreTest {
325327

326328
@Test
327329
@OptIn(ExperimentalCoroutinesApi::class)
328-
fun reconnectRecreatesSessionDeletedWhileDisconnected() =
330+
fun reconnectUpsertsSessionDeletedWhileDisconnected() =
329331
runTest {
330332
val sessionKey = "agent:main:node-device"
331333
val gateway = ScriptedGateway(json)
@@ -337,7 +339,7 @@ class ChatControllerReconnectRestoreTest {
337339
"""{"session":null}"""
338340
}
339341
}
340-
gateway.respond("sessions.create") {
342+
gateway.respond("sessions.patch") {
341343
sessionExists = true
342344
"""{"ok":true,"key":"$sessionKey"}"""
343345
}
@@ -354,7 +356,7 @@ class ChatControllerReconnectRestoreTest {
354356
runCurrent()
355357

356358
assertEquals(2, gateway.callCount("sessions.describe"))
357-
assertEquals(2, gateway.callCount("sessions.create"))
359+
assertEquals(2, gateway.callCount("sessions.patch"))
358360
}
359361

360362
@Test

0 commit comments

Comments
 (0)