feat(apps): rewind and fork a session from native chat bubbles (#110886)

* feat(apps): rewind and fork a session from native chat bubbles

* docs(apps): note lease-parity contract on message fork dispatch

* fix(apps): native i18n inventory sync and doc-comment style for bubble actions

* fix(apps): translate native bubble action strings across locales

* fix(apps): refresh generated iOS localization catalog after rebase
This commit is contained in:
Peter Steinberger
2026-07-18 17:52:03 -07:00
committed by GitHub
parent 47afe8f88f
commit 593ac4ae0c
36 changed files with 1075 additions and 24 deletions
+29 -13
View File
@@ -38211,7 +38211,7 @@
},
{
"kind": "ui-call",
"line": 513,
"line": 515,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Retry Send",
"surface": "apple",
@@ -38219,7 +38219,7 @@
},
{
"kind": "ui-call",
"line": 527,
"line": 529,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Delete",
"surface": "apple",
@@ -38227,7 +38227,7 @@
},
{
"kind": "ui-call",
"line": 561,
"line": 565,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Stop Listening",
"surface": "apple",
@@ -38235,7 +38235,7 @@
},
{
"kind": "ui-call",
"line": 564,
"line": 568,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Listen",
"surface": "apple",
@@ -38243,7 +38243,7 @@
},
{
"kind": "ui-modifier",
"line": 659,
"line": 665,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Jump to latest reply",
"surface": "apple",
@@ -38251,7 +38251,7 @@
},
{
"kind": "ui-named-argument",
"line": 679,
"line": 685,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Refresh",
"surface": "apple",
@@ -38259,7 +38259,7 @@
},
{
"kind": "ui-call",
"line": 1074,
"line": 1080,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Copy Message",
"surface": "apple",
@@ -38267,15 +38267,31 @@
},
{
"kind": "ui-call",
"line": 1097,
"line": 1103,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Open Full Message",
"surface": "apple",
"id": "native.apple.90bc8a8045e7017e"
},
{
"kind": "ui-call",
"line": 1122,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Rewind to Here",
"surface": "apple",
"id": "native.apple.002bfcede229ad4b"
},
{
"kind": "ui-call",
"line": 1142,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Fork from Here",
"surface": "apple",
"id": "native.apple.14df2e4997689fb3"
},
{
"kind": "ui-localized-call",
"line": 1118,
"line": 1170,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Reply",
"surface": "apple",
@@ -38283,7 +38299,7 @@
},
{
"kind": "ui-localized-call",
"line": 1128,
"line": 1180,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "You",
"surface": "apple",
@@ -38291,7 +38307,7 @@
},
{
"kind": "ui-localized-call",
"line": 1130,
"line": 1182,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Assistant",
"surface": "apple",
@@ -38299,7 +38315,7 @@
},
{
"kind": "ui-call",
"line": 1196,
"line": 1248,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@@ -38307,7 +38323,7 @@
},
{
"kind": "ui-modifier",
"line": 1276,
"line": 1328,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "فتح الرسالة الكاملة"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "إرجاع إلى هنا"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "إنشاء تفرّع من هنا"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Vollständige Nachricht öffnen"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Bis hierhin zurückspulen"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Ab hier abzweigen"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Abrir mensaje completo"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Retroceder hasta aquí"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Bifurcar desde aquí"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "باز کردن پیام کامل"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "بازگشت به اینجا"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "از اینجا منشعب شوید"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Ouvrir le message complet"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Revenir ici"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Créer une branche à partir dici"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "पूरा संदेश खोलें"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "यहाँ तक रिवाइंड करें"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "यहाँ से फ़ोर्क करें"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Buka Pesan Lengkap"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Putar Balik ke Sini"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Buat Cabang dari Sini"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Apri il messaggio completo"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Riavvolgi fino a qui"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Crea una diramazione da qui"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "メッセージ全文を開く"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "ここまで巻き戻す"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "ここから分岐"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "전체 메시지 열기"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "여기까지 되돌리기"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "여기에서 분기하기"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Volledig bericht openen"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Terugspoelen naar hier"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Vanaf hier afsplitsen"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Otwórz pełną wiadomość"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Przewiń do tego miejsca"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Utwórz odgałęzienie od tego miejsca"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Abrir mensagem completa"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Rebobinar até aqui"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Criar bifurcação a partir daqui"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Открыть полное сообщение"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Перемотать сюда"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Создать ответвление отсюда"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Öppna hela meddelandet"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Spola tillbaka hit"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Förgrena härifrån"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "เปิดข้อความฉบับเต็ม"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "ย้อนกลับมาที่นี่"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "แยกแขนงจากตรงนี้"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Tam İletiyi Aç"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Buraya Geri Sar"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Buradan Çatallandır"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Відкрити повне повідомлення"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Перемотати сюди"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Створити відгалуження звідси"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "Mở toàn bộ tin nhắn"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "Tua lại đến đây"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "Tạo nhánh từ đây"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "打开完整消息"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "回退到此处"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "从此处分叉"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+10
View File
@@ -23923,6 +23923,16 @@
"source": "Open Full Message",
"translated": "開啟完整訊息"
},
{
"id": "native.apple.002bfcede229ad4b",
"source": "Rewind to Here",
"translated": "倒回此處"
},
{
"id": "native.apple.14df2e4997689fb3",
"source": "Fork from Here",
"translated": "從這裡分支"
},
{
"id": "native.apple.b37ec7bd7dc1579a",
"source": "Reply",
+272
View File
@@ -69905,6 +69905,142 @@
}
}
},
"Fork from Here": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Fork from Here"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "从此处分叉"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "從這裡分支"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Criar bifurcação a partir daqui"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Ab hier abzweigen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Bifurcar desde aquí"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "ここから分岐"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "여기에서 분기하기"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Créer une branche à partir dici"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "यहाँ से फ़ोर्क करें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "إنشاء تفرّع من هنا"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Crea una diramazione da qui"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Buradan Çatallandır"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Створити відгалуження звідси"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Buat Cabang dari Sini"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Utwórz odgałęzienie od tego miejsca"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "แยกแขนงจากตรงนี้"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Tạo nhánh từ đây"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Vanaf hier afsplitsen"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "از اینجا منشعب شوید"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Создать ответвление отсюда"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Förgrena härifrån"
}
}
}
},
"Full": {
"localizations": {
"en": {
@@ -141169,6 +141305,142 @@
}
}
},
"Rewind to Here": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rewind to Here"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "回退到此处"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "倒回此處"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Rebobinar até aqui"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Bis hierhin zurückspulen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Retroceder hasta aquí"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "ここまで巻き戻す"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "여기까지 되돌리기"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Revenir ici"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "यहाँ तक रिवाइंड करें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "إرجاع إلى هنا"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Riavvolgi fino a qui"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Buraya Geri Sar"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Перемотати сюди"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Putar Balik ke Sini"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Przewiń do tego miejsca"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ย้อนกลับมาที่นี่"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Tua lại đến đây"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Terugspoelen naar hier"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "بازگشت به اینجا"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Перемотать сюда"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Spola tillbaka hit"
}
}
}
},
"Roles": {
"localizations": {
"en": {
@@ -271,6 +271,32 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: response).key
}
func rewindSession(
sessionKey: String,
entryId: String) async throws -> OpenClawChatRewindResponse
{
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.rewindSession(
sessionKey: target.sessionKey,
agentID: target.agentID,
entryId: entryId)
let response = try await self.requestSessionMutation(request)
return try JSONDecoder().decode(OpenClawChatRewindResponse.self, from: response)
}
func forkSessionAtMessage(
sessionKey: String,
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
{
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.forkAtMessage(
sessionKey: target.sessionKey,
agentID: target.agentID,
entryId: entryId)
let response = try await self.requestSessionMutation(request)
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: response)
}
func setActiveSessionKey(_ sessionKey: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
@@ -114,7 +114,7 @@ extension MacGatewayChatTransport {
})
}
func forkSession(parentKey: String) async throws -> String {
private func requestSessionAction(_ request: OpenClawChatGatewayRequest) async throws -> Data {
guard let serverLease = await GatewayConnection.shared.captureServerLease() else {
throw OpenClawChatTransportSendError.notDispatched
}
@@ -124,15 +124,67 @@ extension MacGatewayChatTransport {
throw OpenClawChatTransportSendError.notDispatched
}
}
let target = self.sessionTarget(for: parentKey)
let request = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: target.sessionKey,
agentID: target.agentID)
let data = try await GatewayConnection.shared.request(
return try await GatewayConnection.shared.request(
method: request.method,
params: request.params,
timeoutMs: request.timeoutMs,
ifCurrentServerLease: serverLease)
}
func forkSession(parentKey: String) async throws -> String {
let target = self.sessionTarget(for: parentKey)
let request = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: target.sessionKey,
agentID: target.agentID)
let data = try await self.requestSessionAction(request)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data).key
}
func rewindSession(
sessionKey: String,
entryId: String) async throws -> OpenClawChatRewindResponse
{
let target = self.sessionTarget(for: sessionKey)
let request = Self.rewindSessionRequest(
sessionKey: target.sessionKey,
agentID: target.agentID,
entryId: entryId)
let data = try await self.requestSessionAction(request)
return try JSONDecoder().decode(OpenClawChatRewindResponse.self, from: data)
}
func forkSessionAtMessage(
sessionKey: String,
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
{
let target = self.sessionTarget(for: sessionKey)
let request = Self.forkSessionAtMessageRequest(
sessionKey: target.sessionKey,
agentID: target.agentID,
entryId: entryId)
let data = try await self.requestSessionAction(request)
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: data)
}
static func rewindSessionRequest(
sessionKey: String,
agentID: String?,
entryId: String) -> OpenClawChatGatewayRequest
{
OpenClawChatGatewayRequests.rewindSession(
sessionKey: sessionKey,
agentID: agentID,
entryId: entryId)
}
static func forkSessionAtMessageRequest(
sessionKey: String,
agentID: String?,
entryId: String) -> OpenClawChatGatewayRequest
{
OpenClawChatGatewayRequests.forkAtMessage(
sessionKey: sessionKey,
agentID: agentID,
entryId: entryId)
}
}
@@ -70,6 +70,26 @@ struct MacGatewayChatTransportMappingTests {
#expect(request.params["maxChars"]?.value as? Int == 500_000)
}
@Test func `message rewind and fork requests map session targets`() {
let rewind = MacGatewayChatTransport.rewindSessionRequest(
sessionKey: "global",
agentID: "reviewer",
entryId: "msg-42")
let fork = MacGatewayChatTransport.forkSessionAtMessageRequest(
sessionKey: "agent:reviewer:main",
agentID: nil,
entryId: "msg-43")
#expect(rewind.method == "sessions.rewind")
#expect(rewind.params["sessionKey"]?.value as? String == "global")
#expect(rewind.params["agentId"]?.value as? String == "reviewer")
#expect(rewind.params["entryId"]?.value as? String == "msg-42")
#expect(fork.method == "sessions.fork")
#expect(fork.params["sessionKey"]?.value as? String == "agent:reviewer:main")
#expect(fork.params["agentId"] == nil)
#expect(fork.params["entryId"]?.value as? String == "msg-43")
}
@Test func `legacy trace preference migrates to independent defaults once`() throws {
let suiteName = "MacGatewayChatTransportMappingTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
@@ -327,6 +327,38 @@ public enum OpenClawChatGatewayRequests {
timeoutMs: self.mutationTimeoutMs)
}
public static func rewindSession(
sessionKey: String,
agentID: String?,
entryId: String) -> OpenClawChatGatewayRequest
{
var params = self.sessionParams(
sessionKey: sessionKey,
agentID: agentID,
key: "sessionKey")
self.add(entryId, to: &params, key: "entryId")
return OpenClawChatGatewayRequest(
method: "sessions.rewind",
params: params,
timeoutMs: self.mutationTimeoutMs)
}
public static func forkAtMessage(
sessionKey: String,
agentID: String?,
entryId: String) -> OpenClawChatGatewayRequest
{
var params = self.sessionParams(
sessionKey: sessionKey,
agentID: agentID,
key: "sessionKey")
self.add(entryId, to: &params, key: "entryId")
return OpenClawChatGatewayRequest(
method: "sessions.fork",
params: params,
timeoutMs: self.mutationTimeoutMs)
}
public static func subscribeSessionMessages(
sessionKey: String,
agentID: String?) -> OpenClawChatGatewayRequest
@@ -465,9 +497,10 @@ public enum OpenClawChatGatewayRequests {
private static func sessionParams(
sessionKey: String,
agentID: String?) -> [String: AnyCodable]
agentID: String?,
key: String = "key") -> [String: AnyCodable]
{
var params = ["key": AnyCodable(sessionKey)]
var params = [key: AnyCodable(sessionKey)]
self.add(agentID, to: &params, key: "agentId")
return params
}
@@ -516,6 +516,15 @@ public struct OpenClawChatCreateSessionResponse: Codable, Sendable {
public let sessionId: String?
}
public struct OpenClawChatRewindResponse: Codable, Sendable {
public let editorText: String?
}
public struct OpenClawChatForkAtMessageResponse: Codable, Sendable {
public let sessionKey: String
public let editorText: String?
}
public struct OpenClawChatEventPayload: Codable, Sendable {
public let runId: String?
public let sessionKey: String?
@@ -435,6 +435,10 @@ public protocol OpenClawChatTransport: Sendable {
func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease?
func deleteSession(key: String) async throws
func forkSession(parentKey: String) async throws -> String
func rewindSession(sessionKey: String, entryId: String) async throws -> OpenClawChatRewindResponse
func forkSessionAtMessage(
sessionKey: String,
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
func setSessionModel(sessionKey: String, model: String?) async throws
func patchSessionModel(
sessionKey: String,
@@ -744,6 +748,26 @@ extension OpenClawChatTransport {
userInfo: [NSLocalizedDescriptionKey: "sessions.create fork not supported by this transport"])
}
public func rewindSession(
sessionKey _: String,
entryId _: String) async throws -> OpenClawChatRewindResponse
{
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.rewind not supported by this transport"])
}
public func forkSessionAtMessage(
sessionKey _: String,
entryId _: String) async throws -> OpenClawChatForkAtMessageResponse
{
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.fork not supported by this transport"])
}
public func listModels() async throws -> [OpenClawChatModelChoice] {
throw NSError(
domain: "OpenClawChatTransport",
@@ -505,6 +505,8 @@ public struct OpenClawChatView: View {
self.copyMessageButton(for: msg)
self.replyMessageButton(for: msg)
self.openFullMessageButton(for: msg)
self.rewindMessageButton(for: msg)
self.forkMessageButton(for: msg)
if outboxState.isFailed {
Button {
self.viewModel.retryOutboxMessage(msg.id)
@@ -547,6 +549,8 @@ public struct OpenClawChatView: View {
self.copyMessageButton(for: msg)
self.replyMessageButton(for: msg)
self.openFullMessageButton(for: msg)
self.rewindMessageButton(for: msg)
self.forkMessageButton(for: msg)
Button {
if speech.isActive(msg.id) {
speech.stop()
@@ -575,6 +579,8 @@ public struct OpenClawChatView: View {
self.copyMessageButton(for: msg)
self.replyMessageButton(for: msg)
self.openFullMessageButton(for: msg)
self.rewindMessageButton(for: msg)
self.forkMessageButton(for: msg)
}
}
}
@@ -1103,6 +1109,52 @@ extension OpenClawChatView {
}
}
@ViewBuilder
private func rewindMessageButton(for message: OpenClawChatMessage) -> some View {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if role == "user",
message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
Button {
Task { await self.viewModel.rewindToMessage(message) }
} label: {
Label {
Text("Rewind to Here")
.font(OpenClawChatTypography.body)
} icon: {
Image(systemName: "arrow.uturn.backward")
}
}
.disabled(self.messageSessionActionsDisabled)
}
}
@ViewBuilder
private func forkMessageButton(for message: OpenClawChatMessage) -> some View {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if role == "user",
message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
Button {
Task { await self.viewModel.forkAtMessage(message) }
} label: {
Label {
Text("Fork from Here")
.font(OpenClawChatTypography.body)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
}
.disabled(self.messageSessionActionsDisabled)
}
}
private var messageSessionActionsDisabled: Bool {
self.viewModel.hasBlockingRunActivity ||
self.viewModel.isSending ||
self.viewModel.isAborting
}
@ViewBuilder
private func replyMessageButton(for message: OpenClawChatMessage) -> some View {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
@@ -371,6 +371,68 @@ extension OpenClawChatViewModel {
}
}
public func rewindToMessage(_ message: OpenClawChatMessage) async {
guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
let initiatingSession = self.currentSessionSnapshot()
do {
let result = try await self.transport.rewindSession(
sessionKey: initiatingSession.key,
entryId: entryID)
guard self.isCurrentSession(initiatingSession) else { return }
self.replyTarget = nil
self.runMessageScopesByRunID.removeAll()
self.provisionalFinalMessagesByID.removeAll()
self.input = result.editorText ?? ""
let historyRequest = self.beginHistoryRequest(for: initiatingSession)
_ = await self.refreshHistoryAfterRun(historyRequest: historyRequest)
} catch {
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.rewind failed \(error.localizedDescription, privacy: .public)")
}
}
/// Dispatch parity with forkSession(key:): per-request server-lease guards in the
/// transport plus post-RPC staleness re-checks, not the patch-flow route lease,
/// which does not expose fork/rewind and would widen the lease API for no sibling.
public func forkAtMessage(_ message: OpenClawChatMessage) async {
guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
guard self.canCreateSessionForImmediateSwitch() else { return }
let initiatingSession = self.currentSessionSnapshot()
do {
let result = try await self.transport.forkSessionAtMessage(
sessionKey: initiatingSession.key,
entryId: entryID)
let createdKey = result.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !createdKey.isEmpty else { return }
guard self.isCurrentSession(initiatingSession),
!self.hasBlockingRunActivity,
!self.isSending,
!self.isAborting,
self.canCreateSessionForImmediateSwitch()
else {
self.refreshSessions(limit: Self.sessionListFetchLimit)
return
}
self.switchSession(to: createdKey)
guard self.sessionKey == createdKey else { return }
self.input = result.editorText ?? ""
} catch {
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.fork failed \(error.localizedDescription, privacy: .public)")
}
}
private static func sessionMutationEntryID(for message: OpenClawChatMessage) -> String? {
guard message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
else { return nil }
let entryID = message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines)
return entryID?.isEmpty == false ? entryID : nil
}
public func setSessionUnread(key: String, unread: Bool) {
let identityKey = self.sessionMutationIdentity(for: key)
let previousEntry = self.sessions.first(where: { $0.key == key })
@@ -43,6 +43,17 @@ extension OpenClawChatViewModel {
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
return
}
if change.reason == "rewind" {
guard let sessionKey = change.sessionKey,
self.matchesCurrentSessionKey(
incoming: sessionKey,
agentId: change.agentId,
current: self.sessionKey)
else { return }
let context = self.beginHistoryRequest()
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
return
}
guard change.reason == "patch" || change.reason == "command-metadata" else { return }
let context = self.currentSessionSnapshot()
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
@@ -4636,6 +4636,82 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable {
}
}
public struct SessionsRewindParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let entryid: String
public init(
sessionkey: String,
agentid: String? = nil,
entryid: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.entryid = entryid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case entryid = "entryId"
}
}
public struct SessionsRewindResult: Codable, Sendable {
public let editortext: String?
public init(
editortext: String? = nil)
{
self.editortext = editortext
}
private enum CodingKeys: String, CodingKey {
case editortext = "editorText"
}
}
public struct SessionsForkParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let entryid: String
public init(
sessionkey: String,
agentid: String? = nil,
entryid: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.entryid = entryid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case entryid = "entryId"
}
}
public struct SessionsForkResult: Codable, Sendable {
public let sessionkey: String
public let editortext: String?
public init(
sessionkey: String,
editortext: String? = nil)
{
self.sessionkey = sessionkey
self.editortext = editortext
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case editortext = "editorText"
}
}
public struct SessionFileBrowserEntry: Codable, Sendable {
public let path: String
public let name: String
@@ -135,6 +135,31 @@ struct ChatGatewayRequestTests {
#expect(create.params["worktreeBaseRef"]?.value as? String == "origin/release")
}
@Test func `message rewind and fork requests preserve routing identity`() {
let rewind = OpenClawChatGatewayRequests.rewindSession(
sessionKey: "agent:reviewer:telegram:group:1",
agentID: " reviewer ",
entryId: " message-42 ")
let fork = OpenClawChatGatewayRequests.forkAtMessage(
sessionKey: "global",
agentID: nil,
entryId: "message-43")
#expect(rewind.method == "sessions.rewind")
#expect(rewind.timeoutMs == 15000)
#expect(rewind.params["sessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
#expect(rewind.params["agentId"]?.value as? String == "reviewer")
#expect(rewind.params["entryId"]?.value as? String == "message-42")
#expect(rewind.params["key"] == nil)
#expect(fork.method == "sessions.fork")
#expect(fork.timeoutMs == 15000)
#expect(fork.params["sessionKey"]?.value as? String == "global")
#expect(fork.params["agentId"] == nil)
#expect(fork.params["entryId"]?.value as? String == "message-43")
#expect(fork.params["key"] == nil)
}
@Test func `session group requests encode exact gateway contracts`() {
let list = OpenClawChatGatewayRequests.sessionGroupsList()
let put = OpenClawChatGatewayRequests.sessionGroupsPut(names: ["Work", "Personal"])
@@ -5,6 +5,9 @@ import Testing
private actor SessionActionTransportState {
var forkedParentKeys: [String] = []
var rewoundMessages: [(sessionKey: String, entryID: String)] = []
var forkedMessages: [(sessionKey: String, entryID: String)] = []
var historySessionKeys: [String] = []
var patchedKeys: [String] = []
var deletedKeys: [String] = []
var groupPuts: [[String]] = []
@@ -15,6 +18,18 @@ private actor SessionActionTransportState {
self.forkedParentKeys.append(key)
}
func recordRewind(sessionKey: String, entryID: String) {
self.rewoundMessages.append((sessionKey, entryID))
}
func recordForkAtMessage(sessionKey: String, entryID: String) {
self.forkedMessages.append((sessionKey, entryID))
}
func recordHistory(_ sessionKey: String) {
self.historySessionKeys.append(sessionKey)
}
func recordPatch(_ key: String) {
self.patchedKeys.append(key)
}
@@ -69,13 +84,28 @@ private struct SessionActionForkGate: Sendable {
private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTransport {
private let state = SessionActionTransportState()
private let forkGate: SessionActionForkGate?
private let forkAtMessageGate: SessionActionForkGate?
private let rewindEditorText: String?
private let forkAtMessageSessionKey: String
private let forkAtMessageEditorText: String?
init(forkGate: SessionActionForkGate? = nil) {
init(
forkGate: SessionActionForkGate? = nil,
forkAtMessageGate: SessionActionForkGate? = nil,
rewindEditorText: String? = "rewound draft",
forkAtMessageSessionKey: String = "forked-at-message",
forkAtMessageEditorText: String? = "forked draft")
{
self.forkGate = forkGate
self.forkAtMessageGate = forkAtMessageGate
self.rewindEditorText = rewindEditorText
self.forkAtMessageSessionKey = forkAtMessageSessionKey
self.forkAtMessageEditorText = forkAtMessageEditorText
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
OpenClawChatHistoryPayload(
await self.state.recordHistory(sessionKey)
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "session-\(sessionKey)",
messages: [],
@@ -98,6 +128,25 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
return "forked"
}
func rewindSession(
sessionKey: String,
entryId: String) async throws -> OpenClawChatRewindResponse
{
await self.state.recordRewind(sessionKey: sessionKey, entryID: entryId)
return OpenClawChatRewindResponse(editorText: self.rewindEditorText)
}
func forkSessionAtMessage(
sessionKey: String,
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
{
await self.state.recordForkAtMessage(sessionKey: sessionKey, entryID: entryId)
await self.forkAtMessageGate?.suspendCompletion()
return OpenClawChatForkAtMessageResponse(
sessionKey: self.forkAtMessageSessionKey,
editorText: self.forkAtMessageEditorText)
}
func patchSession(
key: String,
label _: String??,
@@ -163,6 +212,18 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
await self.state.forkedParentKeys
}
func rewoundMessages() async -> [(sessionKey: String, entryID: String)] {
await self.state.rewoundMessages
}
func forkedMessages() async -> [(sessionKey: String, entryID: String)] {
await self.state.forkedMessages
}
func historySessionKeys() async -> [String] {
await self.state.historySessionKeys
}
func patchedKeys() async -> [String] {
await self.state.patchedKeys
}
@@ -300,7 +361,7 @@ struct ChatViewModelSessionActionTests {
#expect(await transport.createdAgentIDs() == ["worker"])
}
@Test func `unsupported create with advanced options fails without resetting`() async throws {
@Test func `unsupported create with advanced options fails without resetting`() async {
// SessionActionTransport relies on the protocol's default createSession,
// which throws the canonical unsupported error; the worktree request must
// surface it instead of taking the plain-new reset fallback.
@@ -348,6 +409,77 @@ struct ChatViewModelSessionActionTests {
#expect(await transport.createdParentKeys() == ["main"])
}
@Test func `rewind seeds editor and refreshes history`() async {
let transport = SessionActionTransport(rewindEditorText: "edit this turn")
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.input = "old draft"
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
#expect(viewModel.input == "edit this turn")
#expect(await transport.rewoundMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]])
#expect(await transport.historySessionKeys() == ["main"])
}
@Test func `rewind does not dispatch while busy`() async {
let transport = SessionActionTransport()
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.isSending = true
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
#expect(await transport.rewoundMessages().isEmpty)
#expect(await transport.historySessionKeys().isEmpty)
}
@Test func `fork at message switches and seeds editor`() async {
let transport = SessionActionTransport(
forkAtMessageSessionKey: "agent:main:forked",
forkAtMessageEditorText: "continue here")
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
await viewModel.forkAtMessage(self.userMessage(entryID: "message-42"))
#expect(viewModel.sessionKey == "agent:main:forked")
#expect(viewModel.input == "continue here")
#expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]])
}
@Test func `fork at message completion does not override newer navigation`() async {
let forkGate = SessionActionForkGate()
let transport = SessionActionTransport(
forkAtMessageGate: forkGate,
forkAtMessageSessionKey: "agent:main:forked")
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
let fork = Task { await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) }
guard await self.waitForForkStart(forkGate) else {
forkGate.release()
fork.cancel()
Issue.record("timed out waiting for fork start signal")
return
}
viewModel.switchSession(to: "other")
forkGate.release()
await fork.value
#expect(viewModel.sessionKey == "other")
#expect(viewModel.input.isEmpty)
#expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]])
}
@Test func `remote rewind refreshes current transcript only`() async {
let transport = SessionActionTransport()
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "other", reason: "rewind")))
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "main", reason: "rewind")))
let refreshed = await self.waitForHistoryRequest(transport)
#expect(refreshed)
#expect(await transport.historySessionKeys() == ["main"])
}
@Test func `fork does not mutate gateway while session switching is blocked`() async {
let transport = SessionActionTransport()
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
@@ -404,6 +536,29 @@ struct ChatViewModelSessionActionTests {
}
}
private func waitForHistoryRequest(
_ transport: SessionActionTransport,
timeout: Duration = .seconds(15)) async -> Bool
{
let clock = ContinuousClock()
let deadline = clock.now + timeout
while clock.now < deadline {
if await transport.historySessionKeys().isEmpty == false {
return true
}
await Task.yield()
}
return false
}
private func userMessage(entryID: String) -> OpenClawChatMessage {
OpenClawChatMessage(
role: "user",
content: [],
timestamp: nil,
transcriptMessageID: entryID)
}
private func entry(key: String) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
@@ -423,6 +423,10 @@ import {
SessionsCompactionListResultSchema,
SessionsCompactionRestoreParamsSchema,
SessionsCompactionRestoreResultSchema,
SessionsForkParamsSchema,
SessionsForkResultSchema,
SessionsRewindParamsSchema,
SessionsRewindResultSchema,
SessionFileBrowserEntrySchema,
SessionFileBrowserResultSchema,
SessionCompactionCheckpointSchema,
@@ -688,6 +692,10 @@ export const ProtocolSchemas = {
SessionsCompactionGetResult: SessionsCompactionGetResultSchema,
SessionsCompactionBranchResult: SessionsCompactionBranchResultSchema,
SessionsCompactionRestoreResult: SessionsCompactionRestoreResultSchema,
SessionsRewindParams: SessionsRewindParamsSchema,
SessionsRewindResult: SessionsRewindResultSchema,
SessionsForkParams: SessionsForkParamsSchema,
SessionsForkResult: SessionsForkResultSchema,
SessionFileBrowserEntry: SessionFileBrowserEntrySchema,
SessionFileBrowserResult: SessionFileBrowserResultSchema,
SessionFileKind: SessionFileKindSchema,