fix(android): keep composer media with its chat (#109200)

* fix(android): keep composer media with its chat

* fix(android): retry shared media after chat switch

* fix(android): bind composer media synchronously

* fix(android): bind composer state to chat owners

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* chore(android): keep release notes app-scoped

* refactor(android): reuse localized outbox failure text

* style: format web push state migration

* style: format google meet setup test

* fix(android): restore composer build compatibility

* chore(android): sync native i18n inventory

* fix(android): satisfy composer warning gate

* fix(android): align composer ownership tests

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* test(android): align durable composer assertions

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* test(android): bound outbox scheduler advances

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* fix(ci): recalibrate status startup memory budget (#109887)

* fix(android): admit live composer payload

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* fix(android): refresh native i18n inventory

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* fix(android): bound composer admission receipts

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* fix(android): preserve owner-bound async state

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* fix(android): bind composer drafts to saved state

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

---------

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
WhatsSkiLL
2026-07-17 13:05:38 +01:00
committed by GitHub
co-authored by IWhatsskill Peter Steinberger Peter Steinberger
parent 6745f9a553
commit 6bd9e5f158
83 changed files with 7585 additions and 1117 deletions
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "تعذّر تجهيز مرفق للإرسال."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "تعذّرت إضافة الرسالة إلى قائمة الانتظار لإرسالها لاحقًا."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "حالة Gateway ليست جيدة؛ لا يمكن الإرسال"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "تعذّرت إضافة الرسالة إلى قائمة الانتظار لإرسالها لاحقًا."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "الدردشة"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Ein Anhang konnte nicht für den Versand vorbereitet werden."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Die Nachricht konnte nicht für die spätere Zustellung in die Warteschlange gestellt werden."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway-Status nicht OK; Senden nicht möglich"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Die Nachricht konnte nicht für die spätere Zustellung in die Warteschlange gestellt werden."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Chat"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "No se pudo preparar un archivo adjunto para enviarlo."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "No se pudo poner el mensaje en cola para entregarlo más tarde."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "El estado del Gateway no es correcto; no se puede enviar"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "No se pudo poner el mensaje en cola para entregarlo más tarde."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Chat"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "پیوست برای ارسال آماده نشد."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "پیام برای تحویل در آینده در صف قرار نگرفت."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "وضعیت سلامت Gateway مطلوب نیست؛ ارسال ممکن نیست"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "پیام برای تحویل در آینده در صف قرار نگرفت."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "گفتگو"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Impossible de préparer une pièce jointe pour lenvoi."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Impossible de mettre le message en file dattente pour un envoi ultérieur."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "L’état du Gateway nest pas OK ; envoi impossible"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Impossible de mettre le message en file dattente pour un envoi ultérieur."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Discussion"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "भेजने के लिए अटैचमेंट तैयार नहीं किया जा सका।"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "बाद में डिलीवरी के लिए संदेश को कतार में नहीं लगाया जा सका।"
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway की स्थिति ठीक नहीं है; भेजा नहीं जा सकता"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "बाद में डिलीवरी के लिए संदेश को कतार में नहीं लगाया जा सका।"
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "चैट"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Tidak dapat menyiapkan lampiran untuk dikirim."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Tidak dapat mengantrekan pesan untuk dikirim nanti."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Kondisi Gateway tidak baik; tidak dapat mengirim"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Tidak dapat mengantrekan pesan untuk dikirim nanti."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Obrolan"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Impossibile preparare un allegato per l'invio."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Impossibile mettere il messaggio in coda per inviarlo in seguito."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Lo stato del Gateway non è OK; impossibile inviare"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Impossibile mettere il messaggio in coda per inviarlo in seguito."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Chat"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "送信用の添付ファイルを準備できませんでした。"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "後で配信するメッセージをキューに追加できませんでした。"
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gatewayの状態が正常ではないため、送信できません"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "後で配信するメッセージをキューに追加できませんでした。"
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "チャット"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "전송할 첨부 파일을 준비할 수 없습니다."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "나중에 전송하도록 메시지를 대기열에 추가할 수 없습니다."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway 상태가 정상이 아니므로 전송할 수 없습니다."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "나중에 전송하도록 메시지를 대기열에 추가할 수 없습니다."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "채팅"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Kan een bijlage niet gereedmaken voor verzending."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Kan het bericht niet in de wachtrij plaatsen voor latere bezorging."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway-status is niet OK; verzenden is niet mogelijk"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Kan het bericht niet in de wachtrij plaatsen voor latere bezorging."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Chat"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Nie udało się przygotować załącznika do wysłania."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Nie udało się dodać wiadomości do kolejki w celu późniejszego dostarczenia."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Stan Gateway nie jest prawidłowy; nie można wysłać"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Nie udało się dodać wiadomości do kolejki w celu późniejszego dostarczenia."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Czat"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Não foi possível preparar um anexo para envio."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Não foi possível colocar a mensagem na fila para entrega posterior."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "A integridade do Gateway não está OK; não é possível enviar"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Não foi possível colocar a mensagem na fila para entrega posterior."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Chat"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Не удалось подготовить вложение к отправке."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Не удалось поставить сообщение в очередь для последующей доставки."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Состояние Gateway неудовлетворительное; отправка невозможна"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Не удалось поставить сообщение в очередь для последующей доставки."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Чат"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Det gick inte att förbereda en bilaga för att skickas."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Det gick inte att köa meddelandet för senare leverans."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway-statusen är inte OK; det går inte att skicka"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Det gick inte att köa meddelandet för senare leverans."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Chatt"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "ไม่สามารถเตรียมไฟล์แนบสำหรับส่งได้"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "ไม่สามารถเพิ่มข้อความลงในคิวเพื่อส่งภายหลังได้"
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "สถานะ Gateway ไม่ปกติ ไม่สามารถส่งได้"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "ไม่สามารถเพิ่มข้อความลงในคิวเพื่อส่งภายหลังได้"
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "แชท"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Ek, gönderilmek üzere hazırlanamadı."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Mesaj daha sonra teslim edilmek üzere kuyruğa alınamadı."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway durumu iyi değil; gönderilemiyor"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Mesaj daha sonra teslim edilmek üzere kuyruğa alınamadı."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Sohbet"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Не вдалося підготувати вкладення до надсилання."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Не вдалося додати повідомлення до черги для подальшого доставлення."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Стан Gateway незадовільний; неможливо надіслати"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Не вдалося додати повідомлення до черги для подальшого доставлення."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Чат"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "Không thể chuẩn bị tệp đính kèm để gửi."
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Không thể đưa tin nhắn vào hàng đợi để gửi sau."
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Trạng thái Gateway không ổn định; không thể gửi"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "Không thể đưa tin nhắn vào hàng đợi để gửi sau."
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "Trò chuyện"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "无法准备待发送的附件。"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "无法将消息加入队列以供稍后发送。"
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway 运行状况异常;无法发送"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "无法将消息加入队列以供稍后发送。"
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "聊天"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+15 -5
View File
@@ -1103,11 +1103,6 @@
"source": "Could not stage an attachment for sending.",
"translated": "無法準備要傳送的附件。"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "無法將訊息加入佇列以供稍後傳送。"
},
{
"id": "native.android.c3899241029d152c",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
@@ -1128,6 +1123,11 @@
"source": "Gateway health not OK; cannot send",
"translated": "Gateway 健康狀態異常;無法傳送"
},
{
"id": "native.android.0b29275eefad9863",
"source": "Could not queue message for later delivery.",
"translated": "無法將訊息加入佇列以供稍後傳送。"
},
{
"id": "native.android.8c2ff49af3e78aee",
"source": "Chat failed",
@@ -7398,6 +7398,16 @@
"source": "Chat",
"translated": "聊天"
},
{
"id": "native.android.ec49cca2ed03f9f9",
"source": "Messages to recover",
"translated": "Messages to recover"
},
{
"id": "native.android.7983bb6667118aa1",
"source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
"translated": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows."
},
{
"id": "native.android.882cc14a58e7e105",
"source": "Loading session",
+2
View File
@@ -4,6 +4,8 @@
Adds foreground, on-device Voice Wake with editable Gateway-synced wake words and automatic pause during other voice activity.
Fixes Android composer media leaking across Gateway, agent, and chat switches. Thanks @IWhatsskill.
Fixes malformed Android agent and profile initials when display names begin with emoji. Thanks @Leon-SK668.
Adds a Wear OS companion for sessions, transcripts, text and voice replies, abort controls, reply notifications, and a launch Tile. The watch proxies through the paired phone and stores no Gateway credentials. Thanks @sibbl and @IWhatsskill.
+4
View File
@@ -367,6 +367,10 @@ dependencies {
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
val validateOpenClawReleaseBuildMetadata =
File diff suppressed because it is too large Load Diff
@@ -99,7 +99,8 @@ class NodeApp : Application() {
runBlocking {
database.withTransaction {
database.dao().deleteMessages(gatewayId)
database.dao().deleteSessions(gatewayId)
database.dao().deleteSessionsForGateway(gatewayId)
database.dao().deleteGatewayOwner(gatewayId)
// The outbox owns command/attachment cascade deletes; nested transactions join this one.
RoomChatCommandOutbox(database).clearGateway(gatewayId)
}
@@ -5,11 +5,13 @@ import ai.openclaw.app.chat.ChatCacheDatabase
import ai.openclaw.app.chat.ChatCacheScope
import ai.openclaw.app.chat.ChatCommandEntry
import ai.openclaw.app.chat.ChatCommandOutbox
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.ChatController
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatOutboxItem
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatPlanStep
import ai.openclaw.app.chat.ChatSessionDeletion
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.ChatThinkingLevelSelection
import ai.openclaw.app.chat.ChatTranscriptCache
@@ -17,6 +19,7 @@ import ai.openclaw.app.chat.ChatWidgetResource
import ai.openclaw.app.chat.ChatWidgetSurface
import ai.openclaw.app.chat.ChatWidgetSurfaceUrls
import ai.openclaw.app.chat.ChatWidgetUrlResolver
import ai.openclaw.app.chat.GatewayDefaultAgentOwner
import ai.openclaw.app.chat.MainSessionBinding
import ai.openclaw.app.chat.MessageSpeechClient
import ai.openclaw.app.chat.MessageSpeechController
@@ -885,6 +888,21 @@ class NodeRuntime private constructor(
val talkSetupReadiness: StateFlow<GatewayTalkSetupReadiness> = _talkSetupReadiness.asStateFlow()
private val _gatewayDefaultAgentId = MutableStateFlow<String?>(null)
val gatewayDefaultAgentId: StateFlow<String?> = _gatewayDefaultAgentId.asStateFlow()
private val gatewayDefaultAgentRevision = AtomicLong(0)
private var gatewayDefaultAgentStableId: String? = null
private fun updateGatewayDefaultAgentId(agentId: String?) {
val normalized = agentId?.trim()?.ifEmpty { null }
val ownerStableId = normalized?.let { chatCacheGatewayId() }
if (_gatewayDefaultAgentId.value == normalized && gatewayDefaultAgentStableId == ownerStableId) return
// Revision first: a send may observe either side of the value write, but never a new
// owner paired with the previous epoch during an A -> B -> A transition.
gatewayDefaultAgentRevision.incrementAndGet()
_gatewayDefaultAgentId.value = normalized
gatewayDefaultAgentStableId = ownerStableId
chat.onDefaultAgentChanged(normalized)
}
private val _gatewayAgents = MutableStateFlow<List<GatewayAgentSummary>>(emptyList())
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = _gatewayAgents.asStateFlow()
@@ -1155,7 +1173,6 @@ class NodeRuntime private constructor(
replaceGatewayMethods(emptySet())
_operatorScopes.value = emptyList()
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_gatewayDefaultAgentId.value = null
_gatewayAgents.value = emptyList()
selectedChatAgentId = null
_modelCatalog.value = emptyList()
@@ -1363,6 +1380,19 @@ class NodeRuntime private constructor(
}
}
private val chatSessionDeletionListenerSequence = AtomicLong()
private val chatSessionDeletionListeners = ConcurrentHashMap<Long, (ChatSessionDeletion) -> Unit>()
internal fun addChatSessionDeletionListener(listener: (ChatSessionDeletion) -> Unit): () -> Unit {
val id = chatSessionDeletionListenerSequence.incrementAndGet()
chatSessionDeletionListeners[id] = listener
return { chatSessionDeletionListeners.remove(id) }
}
private fun publishChatSessionDeletion(deletion: ChatSessionDeletion) {
chatSessionDeletionListeners.values.forEach { listener -> listener(deletion) }
}
private val chat: ChatController =
when (mode) {
NodeRuntimeMode.Live ->
@@ -1372,8 +1402,12 @@ class NodeRuntime private constructor(
json = json,
transcriptCache = chatTranscriptCache,
cacheScope = ::chatCacheScope,
currentDefaultAgentId = { gatewayDefaultAgentId.value },
currentDefaultAgentRevision = gatewayDefaultAgentRevision::get,
commandOutbox = chatCommandOutbox,
recordModelRecent = prefs::recordModelRecent,
onSessionDeleted = ::publishChatSessionDeletion,
onOfflineDefaultAgentRestored = ::syncMainSessionKey,
)
NodeRuntimeMode.ScreenshotFixture ->
ChatController(
@@ -2263,6 +2297,8 @@ class NodeRuntime private constructor(
@Volatile private var preferredGatewayReconnectSuppressed = false
val chatSessionKey: StateFlow<String> = chat.sessionKey
val chatSessionOwnerAgentId: StateFlow<String?> = chat.sessionOwnerAgentId
internal val gatewayComposerDefaultAgentOwner: StateFlow<GatewayDefaultAgentOwner?> = chat.composerDefaultAgentOwner
val chatSessionId: StateFlow<String?> = chat.sessionId
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
val chatHistoryLoading: StateFlow<Boolean> = chat.historyLoading
@@ -2293,7 +2329,7 @@ class NodeRuntime private constructor(
_serverName.value = "OpenClaw Gateway"
_remoteAddress.value = "Mac Studio on local network"
_gatewayVersion.value = BuildConfig.VERSION_NAME
_gatewayDefaultAgentId.value = "main"
updateGatewayDefaultAgentId("main")
_gatewayAgents.value = AndroidScreenshotFixture.agents
_modelCatalog.value = AndroidScreenshotFixture.models
_providerModelCatalog.value = AndroidScreenshotFixture.models
@@ -3594,6 +3630,9 @@ class NodeRuntime private constructor(
if (gatewayAuthResetInProgress) return
}
// A user-selected connect target must never inherit notification content from another gateway.
if (gatewayDefaultAgentStableId?.let { it != endpoint.stableId } == true) {
updateGatewayDefaultAgentId(null)
}
notificationOutbox.clear()
invalidateNodeCapabilityApprovalState()
val connectAttemptId = connectAttemptSeq.incrementAndGet()
@@ -3997,6 +4036,7 @@ class NodeRuntime private constructor(
gatewayDataGeneration += 1
clearOperatorGatewayState(retirePendingCronRuns = true)
}
if (retireRunState) updateGatewayDefaultAgentId(null)
invalidateVoiceWakeWordsForGateway()
chat.onGatewayScopeChanging(retireRunState)
stopMessageSpeech()
@@ -4159,9 +4199,12 @@ class NodeRuntime private constructor(
}
}
fun loadChat(sessionKey: String) {
fun loadChat(
sessionKey: String,
ownerAgentId: String? = null,
) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
chat.load(key)
chat.load(key, ownerAgentId)
}
fun refreshChat() {
@@ -4177,6 +4220,7 @@ class NodeRuntime private constructor(
suspend fun patchChatSession(
key: String,
ownerAgentId: String? = null,
label: String? = null,
clearLabel: Boolean = false,
category: String? = null,
@@ -4187,6 +4231,7 @@ class NodeRuntime private constructor(
) {
chat.patchSession(
key = key,
ownerAgentId = ownerAgentId,
label = label,
clearLabel = clearLabel,
category = category,
@@ -4208,11 +4253,15 @@ class NodeRuntime private constructor(
chat.dissolveSessionGroup(group)
}
suspend fun deleteChatSession(key: String) {
chat.deleteSession(key)
}
internal suspend fun deleteChatSession(
key: String,
ownerAgentId: String?,
): ChatSessionDeletion? = chat.deleteSession(key, ownerAgentId)
suspend fun forkChatSession(parentKey: String): String? = chat.forkSession(parentKey)
suspend fun forkChatSession(
parentKey: String,
ownerAgentId: String? = null,
): String? = chat.forkSession(parentKey, ownerAgentId)
fun setChatThinkingLevel(level: String) {
chat.setThinkingLevel(level)
@@ -4225,9 +4274,12 @@ class NodeRuntime private constructor(
chat.setSessionModel(sessionKey = sessionKey, modelRef = modelRef)
}
fun switchChatSession(sessionKey: String) {
fun switchChatSession(
sessionKey: String,
ownerAgentId: String? = null,
) {
stopMessageSpeech()
chat.switchSession(sessionKey)
chat.switchSession(sessionKey, ownerAgentId)
}
fun selectChatAgent(agentId: String) {
@@ -4279,6 +4331,25 @@ class NodeRuntime private constructor(
attachments: List<OutgoingAttachment>,
): Boolean = chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
internal fun canSendForOwner(owner: ChatComposerOwner): Boolean = chat.canSendForOwner(owner)
internal suspend fun sendChatForOwnerAwaitAcceptance(
owner: ChatComposerOwner,
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
idempotencyKey: String,
): Boolean =
chat.sendMessageForOwnerAwaitAcceptance(
message = message,
thinkingLevel = thinking,
attachments = attachments,
expectedOwner = owner,
idempotencyKey = idempotencyKey,
)
internal suspend fun wasChatOutboxCommandAdmitted(id: String): Boolean = chat.wasOutboxCommandAdmitted(id)
fun refreshChatCommands() {
chat.refreshCommands()
}
@@ -4687,7 +4758,7 @@ class NodeRuntime private constructor(
val agents = parseGatewayAgentSummaries(root)
publishGatewayData(gatewayScope) {
_gatewayDefaultAgentId.value = defaultAgentId.ifEmpty { null }
updateGatewayDefaultAgentId(defaultAgentId)
_gatewayAgents.value = agents
val selectedAgentId = selectedChatAgentId?.takeIf { id -> agents.any { it.id == id } }
selectedChatAgentId = selectedAgentId
@@ -13,6 +13,9 @@ import java.util.UUID
/** Upper bound of durable outbox rows per gateway; enqueue is refused beyond this. */
internal const val OUTBOX_MAX_QUEUED = 50
/** Crash-left UI receipts retained per Gateway/agent owner after outbox retirement. */
internal const val OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER = 16
/** Queued commands older than this are expired instead of sending stale instructions. */
internal const val OUTBOX_EXPIRY_MS = 48L * 60L * 60L * 1000L
@@ -25,6 +28,9 @@ internal const val OUTBOX_DELIVERY_UNCONFIRMED_ERROR = "delivery unconfirmed; re
/** Connection-gated command rows never auto-replay across a reconnect; retry needs explicit intent. */
internal const val OUTBOX_CONNECTION_CHANGED_ERROR = "connection changed before this command was sent; retry manually"
/** Owner-less migrated rows stay parked because their original default agent cannot be proven. */
internal const val OUTBOX_OWNER_CHANGED_ERROR = "chat owner changed before this message was sent; retry from the original chat"
/**
* gatedEpoch sentinel for rows migrated from schemas without epochs: it matches no live
* connection generation, so legacy command-shaped rows park instead of auto-replaying.
@@ -87,6 +93,9 @@ data class ChatOutboxItem(
// Non-null marks a connection-gated row (slash command): it may only auto-send while this
// connection epoch is still active, so reconnects never silently replay a command.
val gatedEpoch: Long? = null,
// Captured at admission and sent explicitly on every replay. Unscoped session keys otherwise
// follow the gateway's mutable default agent and can cross owners after process restart.
val ownerAgentId: String?,
val attachments: List<ChatOutboxAttachment> = emptyList(),
)
@@ -136,6 +145,9 @@ interface ChatCommandOutbox {
/** All rows for [gatewayId] with attachment metadata, strictly createdAt-ordered. */
suspend fun load(gatewayId: String): List<ChatOutboxItem>
/** True when the exact UI idempotency key committed, even if history retired its command row. */
suspend fun wasAdmitted(id: String): Boolean
suspend fun enqueue(
gatewayId: String,
sessionKey: String,
@@ -144,6 +156,8 @@ interface ChatCommandOutbox {
nowMs: Long,
attachments: List<OutboxAttachmentPayload> = emptyList(),
gatedEpoch: Long? = null,
ownerAgentId: String,
idempotencyKey: String? = null,
): ChatOutboxEnqueueResult
/** Re-assembles the attachment bytes for one command, in stable position order. */
@@ -191,10 +205,14 @@ interface ChatCommandOutbox {
id: String,
nowMs: Long,
gatedEpoch: Long?,
ownerAgentId: String? = null,
): Int
suspend fun delete(id: String)
/** Deletes only an undispatched row; false means another lane already claimed or retired it. */
suspend fun deleteIfQueued(id: String): Boolean
/** Retires rows proven delivered by canonical history; returns how many rows were removed. */
suspend fun confirmDelivered(ids: Set<String>): Int
@@ -202,6 +220,7 @@ interface ChatCommandOutbox {
suspend fun deleteForSession(
gatewayId: String,
sessionKey: String,
ownerAgentId: String,
)
/** Drops every queued command owned by one gateway identity. */
@@ -233,6 +252,15 @@ internal data class OutboxCommandEntity(
val retryCount: Int,
val lastError: String?,
val gatedEpoch: Long?,
val ownerAgentId: String?,
)
@Entity(tableName = "composer_send_admissions")
internal data class ComposerSendAdmissionEntity(
@PrimaryKey val id: String,
val gatewayId: String,
val ownerAgentId: String,
val sessionKey: String,
)
@Entity(
@@ -266,10 +294,14 @@ internal interface ChatOutboxDao {
@Query("SELECT * FROM outbox_commands WHERE gatewayId = :gatewayId ORDER BY createdAtMs ASC, id ASC")
suspend fun commands(gatewayId: String): List<OutboxCommandEntity>
@Query("SELECT id FROM outbox_commands WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
@Query(
"SELECT id FROM outbox_commands WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey " +
"AND ownerAgentId = :ownerAgentId",
)
suspend fun commandIdsForSession(
gatewayId: String,
sessionKey: String,
ownerAgentId: String,
): List<String>
@Query("SELECT id FROM outbox_commands WHERE gatewayId = :gatewayId")
@@ -325,7 +357,8 @@ internal interface ChatOutboxDao {
@Query(
"UPDATE outbox_commands SET status = :queuedStatus, retryCount = 0, lastError = NULL, createdAtMs = :createdAtMs, " +
"gatedEpoch = :gatedEpoch WHERE id = :id AND gatewayId = :gatewayId AND status = :failedStatus",
"gatedEpoch = :gatedEpoch, ownerAgentId = COALESCE(ownerAgentId, :ownerAgentId) " +
"WHERE id = :id AND gatewayId = :gatewayId AND status = :failedStatus",
)
suspend fun requeueForRetry(
id: String,
@@ -334,6 +367,7 @@ internal interface ChatOutboxDao {
queuedStatus: String,
failedStatus: String,
gatedEpoch: Long?,
ownerAgentId: String?,
): Int
@Query(
@@ -351,6 +385,47 @@ internal interface ChatOutboxDao {
@Query("DELETE FROM outbox_commands WHERE id = :id")
suspend fun delete(id: String): Int
@Query("SELECT status FROM outbox_commands WHERE id = :id")
suspend fun status(id: String): String?
@Query("SELECT EXISTS(SELECT 1 FROM composer_send_admissions WHERE id = :id)")
suspend fun hasAdmissionReceipt(id: String): Boolean
@Insert
suspend fun insertAdmissionReceipt(row: ComposerSendAdmissionEntity)
@Query("DELETE FROM composer_send_admissions WHERE id = :id")
suspend fun deleteAdmissionReceipt(id: String): Int
// Live command rows remain recovery proof even during a send burst. The agent-wide window
// bounds retired receipts across sessions while a lifecycle save catches up with SavedState.
@Query(
"DELETE FROM composer_send_admissions WHERE gatewayId = :gatewayId AND ownerAgentId = :ownerAgentId " +
"AND id NOT IN (SELECT id FROM outbox_commands) " +
"AND rowid NOT IN " +
"(SELECT rowid FROM composer_send_admissions WHERE gatewayId = :gatewayId AND ownerAgentId = :ownerAgentId " +
"AND id NOT IN (SELECT id FROM outbox_commands) " +
"ORDER BY rowid DESC LIMIT :keep)",
)
suspend fun pruneAdmissionReceipts(
gatewayId: String,
ownerAgentId: String,
keep: Int,
)
@Query("DELETE FROM composer_send_admissions WHERE gatewayId = :gatewayId")
suspend fun deleteAdmissionReceiptsForGateway(gatewayId: String)
@Query(
"DELETE FROM composer_send_admissions WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey " +
"AND ownerAgentId = :ownerAgentId",
)
suspend fun deleteAdmissionReceiptsForSession(
gatewayId: String,
sessionKey: String,
ownerAgentId: String,
)
@Query("SELECT * FROM outbox_attachments WHERE commandId IN (:commandIds) ORDER BY position ASC")
suspend fun attachmentsForCommands(commandIds: List<String>): List<OutboxAttachmentEntity>
@@ -400,6 +475,11 @@ class RoomChatCommandOutbox internal constructor(
return rows.map { row -> row.toItem(attachmentsByCommand[row.id].orEmpty()) }
}
override suspend fun wasAdmitted(id: String): Boolean {
val dao = database.outboxDao()
return dao.status(id) != null || dao.hasAdmissionReceipt(id)
}
override suspend fun enqueue(
gatewayId: String,
sessionKey: String,
@@ -408,9 +488,12 @@ class RoomChatCommandOutbox internal constructor(
nowMs: Long,
attachments: List<OutboxAttachmentPayload>,
gatedEpoch: Long?,
ownerAgentId: String,
idempotencyKey: String?,
): ChatOutboxEnqueueResult {
val gateway = scopedGatewayId(gatewayId) ?: return ChatOutboxEnqueueResult.Unavailable
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return ChatOutboxEnqueueResult.Unavailable
val owner = ownerAgentId.trim().takeIf { it.isNotEmpty() } ?: return ChatOutboxEnqueueResult.Unavailable
val attachmentBytes = attachments.sumOf { it.bytes.size.toLong() }
if (attachmentBytes > OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES) {
return ChatOutboxEnqueueResult.AttachmentsTooLarge
@@ -432,9 +515,10 @@ class RoomChatCommandOutbox internal constructor(
// Monotonic per-gateway createdAt keeps flush strictly FIFO even when two sends land
// in the same wall-clock millisecond (the id tiebreak is a random UUID otherwise).
val createdAt = maxOf(nowMs, (dao.maxCreatedAt(gateway) ?: Long.MIN_VALUE) + 1)
val requestedId = idempotencyKey?.trim()?.takeIf { it.isNotEmpty() }
val entity =
OutboxCommandEntity(
id = UUID.randomUUID().toString(),
id = requestedId ?: UUID.randomUUID().toString(),
gatewayId = gateway,
sessionKey = key,
text = text,
@@ -444,7 +528,25 @@ class RoomChatCommandOutbox internal constructor(
retryCount = 0,
lastError = null,
gatedEpoch = gatedEpoch,
ownerAgentId = owner,
)
if (requestedId != null) {
// The receipt commits with the row and outlives history retirement. SavedState can then
// prove this draft was admitted even if reconnect reconciliation already deleted the row.
dao.insertAdmissionReceipt(
ComposerSendAdmissionEntity(
id = requestedId,
gatewayId = gateway,
ownerAgentId = owner,
sessionKey = key,
),
)
dao.pruneAdmissionReceipts(
gatewayId = gateway,
ownerAgentId = owner,
keep = OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER,
)
}
dao.insert(entity)
val storedAttachments =
attachments.mapIndexed { position, payload ->
@@ -527,6 +629,7 @@ class RoomChatCommandOutbox internal constructor(
id: String,
nowMs: Long,
gatedEpoch: Long?,
ownerAgentId: String?,
): Int {
val gateway = scopedGatewayId(gatewayId) ?: return 0
val dao = database.outboxDao()
@@ -544,6 +647,7 @@ class RoomChatCommandOutbox internal constructor(
queuedStatus = ChatOutboxStatus.Queued.dbValue,
failedStatus = ChatOutboxStatus.Failed.dbValue,
gatedEpoch = gatedEpoch,
ownerAgentId = ownerAgentId?.trim()?.takeIf { it.isNotEmpty() },
)
if (transitioned > 0) {
// Queued same-session successors follow the retried row in their original order, so
@@ -570,6 +674,15 @@ class RoomChatCommandOutbox internal constructor(
}
}
override suspend fun deleteIfQueued(id: String): Boolean =
database.withTransaction {
val dao = database.outboxDao()
if (dao.status(id) != ChatOutboxStatus.Queued.dbValue) return@withTransaction false
val deleted = deleteCommandRowLocked(id) > 0
if (deleted) dao.deleteAdmissionReceipt(id)
deleted
}
override suspend fun confirmDelivered(ids: Set<String>): Int {
if (ids.isEmpty()) return 0
return database.withTransaction {
@@ -584,14 +697,17 @@ class RoomChatCommandOutbox internal constructor(
override suspend fun deleteForSession(
gatewayId: String,
sessionKey: String,
ownerAgentId: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
val owner = ownerAgentId.trim().takeIf { it.isNotEmpty() } ?: return
val dao = database.outboxDao()
database.withTransaction {
for (id in dao.commandIdsForSession(gateway, key)) {
for (id in dao.commandIdsForSession(gateway, key, owner)) {
deleteCommandRowLocked(id)
}
dao.deleteAdmissionReceiptsForSession(gateway, key, owner)
}
}
@@ -602,6 +718,7 @@ class RoomChatCommandOutbox internal constructor(
for (id in dao.commandIdsForGateway(gateway)) {
deleteCommandRowLocked(id)
}
dao.deleteAdmissionReceiptsForGateway(gateway)
}
}
@@ -665,6 +782,7 @@ private fun OutboxCommandEntity.toItem(attachments: List<OutboxAttachmentEntity>
retryCount = retryCount,
lastError = lastError,
gatedEpoch = gatedEpoch,
ownerAgentId = ownerAgentId,
attachments = attachments.map { it.toAttachment() },
)
@@ -0,0 +1,65 @@
package ai.openclaw.app.chat
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
/** Identifies the gateway chat that owns transient composer state and async results. */
internal data class ChatComposerOwner(
val gatewayStableId: String?,
val agentId: String,
val sessionKey: String,
val routingVerified: Boolean = true,
)
/** Last routing owner proven for one gateway, retained while that gateway reconnects. */
internal data class GatewayDefaultAgentOwner(
val gatewayStableId: String,
val agentId: String,
)
internal fun resolveGatewayDefaultAgentId(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
lastVerifiedOwner: GatewayDefaultAgentOwner?,
): String? =
gatewayDefaultAgentId?.trim()?.takeIf { it.isNotEmpty() }
?: lastVerifiedOwner
?.takeIf { it.gatewayStableId == gatewayStableId }
?.agentId
internal fun resolveChatComposerOwner(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
lastVerifiedOwner: GatewayDefaultAgentOwner? = null,
sessionKey: String,
mainSessionKey: String,
): ChatComposerOwner {
val effectiveSessionKey = sessionKey.trim().ifEmpty { mainSessionKey.trim().ifEmpty { "main" } }
val explicitAgentId = resolveAgentIdFromMainSessionKey(effectiveSessionKey)
val effectiveDefaultAgentId = resolveGatewayDefaultAgentId(gatewayStableId, gatewayDefaultAgentId, lastVerifiedOwner)
return ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = explicitAgentId ?: effectiveDefaultAgentId ?: "main",
sessionKey = effectiveSessionKey,
routingVerified = explicitAgentId != null || effectiveDefaultAgentId != null,
)
}
/** Returns an owner only when routing can be proven from the session key or gateway hello. */
internal fun resolveChatComposerRoutingOwner(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
sessionKey: String,
mainSessionKey: String,
): ChatComposerOwner? {
val effectiveSessionKey = sessionKey.trim().ifEmpty { mainSessionKey.trim().ifEmpty { "main" } }
val agentId =
resolveAgentIdFromMainSessionKey(effectiveSessionKey)
?: gatewayDefaultAgentId?.trim()?.takeIf(String::isNotEmpty)
?: return null
return ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = agentId,
sessionKey = effectiveSessionKey,
routingVerified = true,
)
}
File diff suppressed because it is too large Load Diff
@@ -145,6 +145,7 @@ internal val defaultChatThinkingLevelSelection =
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val ownerAgentId: String? = null,
val displayName: String? = null,
val label: String? = null,
val category: String? = null,
@@ -17,7 +17,7 @@ import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.util.UUID
/** Upper bound of cached session rows per gateway; oldest list positions are evicted on write. */
/** Upper bound of cached session rows per gateway across every agent owner. */
internal const val MAX_CACHED_SESSIONS = 50
internal const val CHAT_TRANSCRIPT_CACHE_DB_NAME = "chat-transcript-cache.db"
@@ -33,21 +33,34 @@ internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200
* session-list window so its transcript remains available offline.
*/
interface ChatTranscriptCache {
suspend fun loadSessions(gatewayId: String): List<ChatSessionEntry>
suspend fun loadLastDefaultAgentId(gatewayId: String): String?
suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
)
suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry>
suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage>
suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String? = null,
)
suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
)
@@ -55,6 +68,7 @@ interface ChatTranscriptCache {
/** Removes one session and its transcript, so gateway-side deletes also purge offline copies. */
suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@@ -62,9 +76,10 @@ interface ChatTranscriptCache {
suspend fun clearGateway(gatewayId: String)
}
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "sessionKey"])
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"])
internal data class CachedSessionEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
@@ -72,9 +87,10 @@ internal data class CachedSessionEntity(
val rowOrder: Int,
)
@Entity(tableName = "cached_messages", primaryKeys = ["gatewayId", "sessionKey", "rowOrder"])
@Entity(tableName = "cached_messages", primaryKeys = ["gatewayId", "agentId", "sessionKey", "rowOrder"])
internal data class CachedMessageEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val rowOrder: Int,
val role: String,
@@ -85,76 +101,129 @@ internal data class CachedMessageEntity(
val idempotencyKey: String?,
)
@Entity(tableName = "cached_gateway_owners", primaryKeys = ["gatewayId"])
internal data class CachedGatewayOwnerEntity(
val gatewayId: String,
val agentId: String,
)
@Dao
internal interface ChatCacheDao {
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId ORDER BY rowOrder ASC")
suspend fun sessions(gatewayId: String): List<CachedSessionEntity>
@Query("SELECT agentId FROM cached_gateway_owners WHERE gatewayId = :gatewayId")
suspend fun lastDefaultAgentId(gatewayId: String): String?
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertGatewayOwner(row: CachedGatewayOwnerEntity)
@Query("DELETE FROM cached_gateway_owners WHERE gatewayId = :gatewayId")
suspend fun deleteGatewayOwner(gatewayId: String)
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId ORDER BY rowOrder ASC")
suspend fun sessions(
gatewayId: String,
agentId: String,
): List<CachedSessionEntity>
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun session(
gatewayId: String,
agentId: String,
sessionKey: String,
): CachedSessionEntity?
@Query(
"SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey ORDER BY rowOrder ASC",
"SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId " +
"AND sessionKey = :sessionKey ORDER BY rowOrder ASC",
)
suspend fun messages(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<CachedMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSessions(rows: List<CachedSessionEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSessionStub(row: CachedSessionEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(rows: List<CachedMessageEntity>)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId")
suspend fun deleteSessions(
gatewayId: String,
agentId: String,
)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId")
suspend fun deleteSessions(gatewayId: String)
suspend fun deleteSessionsForGateway(gatewayId: String)
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId")
suspend fun deleteMessages(gatewayId: String)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun deleteSessionRow(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun deleteTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@Query("SELECT COALESCE(MAX(rowOrder), -1) + 1 FROM cached_sessions WHERE gatewayId = :gatewayId")
suspend fun nextSessionRowOrder(gatewayId: String): Int
@Query("SELECT COALESCE(MAX(rowOrder), -1) + 1 FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId")
suspend fun nextSessionRowOrder(
gatewayId: String,
agentId: String,
): Int
// Keeps the just-written session even when the cache is full: without the exclusion, a stub
// inserted at the highest rowOrder would be evicted immediately and deep-session transcripts
// could never be cached once MAX_CACHED_SESSIONS rows exist.
@Query(
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey != :keepSessionKey AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey != :keepSessionKey " +
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId " +
"AND sessionKey != :keepSessionKey AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey != :keepSessionKey " +
"ORDER BY rowOrder ASC LIMIT :keep)",
)
suspend fun evictSessionsBeyondKeeping(
gatewayId: String,
agentId: String,
keepSessionKey: String,
keep: Int,
)
// Transcripts must never outlive their session row; this keeps total cache size bounded
// by MAX_CACHED_SESSIONS * MAX_CACHED_MESSAGES_PER_SESSION rows per gateway.
// Owner-local cleanup runs before the gateway-wide bound below; transcripts never outlive
// their corresponding session row.
@Query(
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId)",
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId)",
)
suspend fun evictOrphanedTranscripts(gatewayId: String)
suspend fun evictOrphanedTranscripts(
gatewayId: String,
agentId: String,
)
// A gateway can expose many agent owners. Cap their aggregate cache by recent writes so
// switching owners cannot grow the disposable session/transcript tables without bound.
@Query(
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND rowid NOT IN " +
"(SELECT rowid FROM cached_sessions WHERE gatewayId = :gatewayId ORDER BY rowid DESC LIMIT :keep)",
)
suspend fun evictGatewaySessionsBeyond(
gatewayId: String,
keep: Int,
)
@Query(
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND NOT EXISTS " +
"(SELECT 1 FROM cached_sessions WHERE cached_sessions.gatewayId = cached_messages.gatewayId " +
"AND cached_sessions.agentId = cached_messages.agentId " +
"AND cached_sessions.sessionKey = cached_messages.sessionKey)",
)
suspend fun evictGatewayOrphanedTranscripts(gatewayId: String)
}
@Database(
@@ -164,8 +233,10 @@ internal interface ChatCacheDao {
OutboxCommandEntity::class,
OutboxAttachmentEntity::class,
OutboxAttachmentChunkEntity::class,
ComposerSendAdmissionEntity::class,
CachedGatewayOwnerEntity::class,
],
version = 4,
version = 8,
exportSchema = false,
)
internal abstract class ChatCacheDatabase : RoomDatabase() {
@@ -215,13 +286,92 @@ internal abstract class ChatCacheDatabase : RoomDatabase() {
}
}
internal val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT")
// Agent-qualified keys carry a durable owner in the key itself. Backfill it so session
// deletion and replay keep working after upgrade without consulting mutable defaults.
db.execSQL(
"UPDATE outbox_commands SET ownerAgentId = " +
"substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " +
"WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1",
)
// Earlier rows did not persist the default agent that owned an unscoped key. Never
// guess after upgrade: queued input stays visible for manual resend, while accepted
// input remains delivery-ambiguous and must not be replayed under a different owner.
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_OWNER_CHANGED_ERROR,
ChatOutboxStatus.Queued.dbValue,
),
)
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
ChatOutboxStatus.Accepted.dbValue,
),
)
}
}
internal val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Session and transcript caches are disposable, and legacy unscoped rows have no
// provable owner. Rebuild both; the durable outbox remains intact across the upgrade.
db.execSQL("DROP TABLE IF EXISTS `cached_sessions`")
db.execSQL("DROP TABLE IF EXISTS `cached_messages`")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
"`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " +
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
"`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " +
"`timestampMs` INTEGER, `idempotencyKey` TEXT, " +
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
)
}
}
internal val MIGRATION_6_7 =
object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
)
}
}
internal val MIGRATION_7_8 =
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `composer_send_admissions` " +
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " +
"`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
)
}
}
fun open(
context: Context,
name: String = CHAT_TRANSCRIPT_CACHE_DB_NAME,
): ChatCacheDatabase =
Room
.databaseBuilder(context, ChatCacheDatabase::class.java, name)
.addMigrations(MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
// v1 has only disposable transcripts. Starting with v2, the outbox is user data, so every
// supported bump needs an explicit migration; destructive fallback remains for v1 only.
.fallbackToDestructiveMigrationFrom(true, 1)
@@ -239,12 +389,35 @@ class RoomChatTranscriptCache internal constructor(
private val json = Json
private val textPartsSerializer = ListSerializer(String.serializer())
override suspend fun loadSessions(gatewayId: String): List<ChatSessionEntry> {
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? {
val gateway = scopedGatewayId(gatewayId) ?: return null
return database
.dao()
.lastDefaultAgentId(gateway)
?.trim()
?.takeIf { it.isNotEmpty() }
}
override suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
database.dao().upsertGatewayOwner(CachedGatewayOwnerEntity(gatewayId = gateway, agentId = agent))
}
override suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry> {
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
return database.dao().sessions(gateway).map { row ->
val agent = scopedAgentId(agentId) ?: return emptyList()
return database.dao().sessions(gateway, agent).map { row ->
ChatSessionEntry(
key = row.sessionKey,
updatedAtMs = row.updatedAtMs,
ownerAgentId = agent,
displayName = row.displayName,
)
}
@@ -252,11 +425,13 @@ class RoomChatTranscriptCache internal constructor(
override suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage> {
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
val agent = scopedAgentId(agentId) ?: return emptyList()
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return emptyList()
return database.dao().messages(gateway, key).mapNotNull { row ->
return database.dao().messages(gateway, agent, key).mapNotNull { row ->
val role = normalizeVisibleChatMessageRole(row.role) ?: return@mapNotNull null
ChatMessage(
id = UUID.randomUUID().toString(),
@@ -270,10 +445,12 @@ class RoomChatTranscriptCache internal constructor(
override suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String?,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val retainedKey = retainedSessionKey?.trim()?.takeIf { it.isNotEmpty() }
val dao = database.dao()
database.withTransaction {
@@ -285,12 +462,13 @@ class RoomChatTranscriptCache internal constructor(
retainedEntry?.let { entry ->
CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = entry.key,
displayName = entry.displayName,
updatedAtMs = entry.updatedAtMs,
rowOrder = 0,
)
} ?: dao.session(gateway, retainedKey)
} ?: dao.session(gateway, agent, retainedKey)
} else {
null
}
@@ -299,25 +477,30 @@ class RoomChatTranscriptCache internal constructor(
sessions.take(listedSessionLimit).mapIndexed { index, session ->
CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = session.key,
displayName = session.displayName,
updatedAtMs = session.updatedAtMs,
rowOrder = index,
)
}
dao.deleteSessions(gateway)
dao.deleteSessions(gateway, agent)
dao.insertSessions(rows)
retainedRow?.let { dao.insertSessions(listOf(it.copy(rowOrder = rows.size))) }
dao.evictOrphanedTranscripts(gateway)
dao.evictOrphanedTranscripts(gateway, agent)
dao.evictGatewaySessionsBeyond(gateway, MAX_CACHED_SESSIONS)
dao.evictGatewayOrphanedTranscripts(gateway)
}
}
override suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
// Text rows only: attachment/binary parts are dropped, and messages without any text are skipped.
val rows =
@@ -331,6 +514,7 @@ class RoomChatTranscriptCache internal constructor(
.mapIndexed { index, (message, role, textParts) ->
CachedMessageEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = key,
rowOrder = index,
role = role,
@@ -341,21 +525,30 @@ class RoomChatTranscriptCache internal constructor(
}
val dao = database.dao()
database.withTransaction {
dao.deleteTranscript(gateway, key)
dao.deleteTranscript(gateway, agent, key)
dao.insertMessages(rows)
// A transcript may arrive for a session missing from the cached list (e.g. deep session
// switch); keep a stub row so the transcript stays reachable, then re-apply the bounds.
dao.insertSessionStub(
CachedSessionEntity(
gatewayId = gateway,
sessionKey = key,
displayName = null,
updatedAtMs = null,
rowOrder = dao.nextSessionRowOrder(gateway),
val currentSession = dao.session(gateway, agent, key)
// REPLACE refreshes SQLite rowid, making the transcript's session the most recent gateway
// row while preserving list metadata when that session was already cached.
dao.insertSessions(
listOf(
currentSession
?: CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = key,
displayName = null,
updatedAtMs = null,
rowOrder = dao.nextSessionRowOrder(gateway, agent),
),
),
)
dao.evictSessionsBeyondKeeping(gateway, keepSessionKey = key, keep = MAX_CACHED_SESSIONS - 1)
dao.evictOrphanedTranscripts(gateway)
dao.evictSessionsBeyondKeeping(gateway, agent, keepSessionKey = key, keep = MAX_CACHED_SESSIONS - 1)
dao.evictOrphanedTranscripts(gateway, agent)
dao.evictGatewaySessionsBeyond(gateway, MAX_CACHED_SESSIONS)
dao.evictGatewayOrphanedTranscripts(gateway)
}
}
@@ -364,24 +557,29 @@ class RoomChatTranscriptCache internal constructor(
val dao = database.dao()
database.withTransaction {
dao.deleteMessages(gateway)
dao.deleteSessions(gateway)
dao.deleteSessionsForGateway(gateway)
dao.deleteGatewayOwner(gateway)
}
}
override suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
val dao = database.dao()
database.withTransaction {
dao.deleteSessionRow(gateway, key)
dao.deleteTranscript(gateway, key)
dao.deleteSessionRow(gateway, agent, key)
dao.deleteTranscript(gateway, agent, key)
}
}
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }
private fun scopedAgentId(agentId: String): String? = agentId.trim().takeIf { it.isNotEmpty() }
private fun decodeTextParts(encoded: String): List<String> = runCatching { json.decodeFromString(textPartsSerializer, encoded) }.getOrDefault(emptyList())
}
@@ -37,6 +37,7 @@ internal sealed interface VoiceNoteRecorderState {
internal data class VoiceNoteRecording(
val file: File,
val durationMs: Long,
val id: String = file.absolutePath,
)
internal interface VoiceNoteRecordingEngine {
@@ -72,10 +73,11 @@ internal class VoiceNoteRecorderController(
val inputLevel: StateFlow<Float> = _inputLevel.asStateFlow()
private var outputFile: File? = null
private var recordingId: String? = null
private var elapsedJob: Job? = null
private var ownsMic = false
suspend fun start(): Boolean {
suspend fun start(id: String = UUID.randomUUID().toString()): Boolean {
synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Idle && _state.value !is VoiceNoteRecorderState.Failure) return false
}
@@ -95,7 +97,7 @@ internal class VoiceNoteRecorderController(
ownsMic = true
val startedAt = elapsedRealtimeMillis()
val file = File(outputDirectory, "voice-note-${UUID.randomUUID()}.m4a")
val file = File(outputDirectory, "voice-note-$id.m4a")
try {
engine.start(file)
} catch (_: Throwable) {
@@ -107,6 +109,7 @@ internal class VoiceNoteRecorderController(
}
outputFile = file
recordingId = id
_elapsedMs.value = 0L
_state.value = VoiceNoteRecorderState.Recording(startedAtMillis = startedAt)
startElapsedUpdates(startedAt)
@@ -119,6 +122,7 @@ internal class VoiceNoteRecorderController(
synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Recording) return false
val file = outputFile ?: return false
val id = recordingId ?: return false
elapsedJob?.cancel()
elapsedJob = null
@@ -153,7 +157,7 @@ internal class VoiceNoteRecorderController(
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Preparing
releaseMicLocked()
VoiceNoteRecording(file = file, durationMs = durationMs)
VoiceNoteRecording(file = file, durationMs = durationMs, id = id)
}
onFinished(recording)
return true
@@ -163,11 +167,17 @@ internal class VoiceNoteRecorderController(
synchronized(lock) {
if (_state.value is VoiceNoteRecorderState.Preparing) {
outputFile = null
recordingId = null
_state.value = VoiceNoteRecorderState.Idle
}
}
}
fun canCommitPreparation(id: String): Boolean =
synchronized(lock) {
_state.value is VoiceNoteRecorderState.Preparing && recordingId == id
}
fun cancel() {
synchronized(lock) {
elapsedJob?.cancel()
@@ -178,6 +188,7 @@ internal class VoiceNoteRecorderController(
releaseMicLocked()
outputFile?.delete()
outputFile = null
recordingId = null
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Idle
@@ -188,6 +199,7 @@ internal class VoiceNoteRecorderController(
synchronized(lock) {
outputFile?.delete()
outputFile = null
recordingId = null
failLocked(message)
}
}
@@ -226,6 +238,7 @@ internal class VoiceNoteRecorderController(
private fun finishFailureLocked(message: String) {
releaseMicLocked()
outputFile = null
recordingId = null
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Failure(message)
@@ -62,6 +62,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
"\${hours}h ago" to R.string.native_f14cd8dd44162255,
"\${if (tls) \"https\" else \"http\"}://\$displayHost" to R.string.native_71dba3ccde0a371b,
"\${if (tls) \"https\" else \"http\"}://\$displayHost:\$port" to R.string.native_3da4811eafea080a,
"\${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows." to R.string.native_e3399e6ed762c252,
"\${job.scheduleLabel} · \${formatCronWake(job.nextRunAtMs)} · \${job.promptPreview}" to R.string.native_fad13bdbe9a8cd0f,
"\${minutes}m" to R.string.native_adb7eb8a03ad699c,
"\${minutes}m ago" to R.string.native_7c55b5030e55a687,
@@ -663,6 +664,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
"Message OpenClaw" to R.string.native_bf69032f2b2b01a2,
"Message actions" to R.string.native_f532ee1f7288365e,
"Message copied" to R.string.native_ed112278453ffe77,
"Messages to recover" to R.string.native_790d78931c21b895,
"Messaging surfaces connected to this gateway." to R.string.native_e5584bc5057284ae,
"Mic off" to R.string.native_003aaf9e0a0e6b9f,
"Mic off · sending…" to R.string.native_dc758e44eed7b7c3,
@@ -73,7 +73,7 @@ internal fun CommandPalette(
onOpenSessions: () -> Unit,
onOpenProviders: () -> Unit,
onOpenSettings: () -> Unit,
onOpenSession: (String) -> Unit,
onOpenSession: (String, String?) -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
@@ -172,6 +172,7 @@ internal fun CommandPalette(
sessionRows.map { session ->
CommandSessionRow(
key = session.key,
ownerAgentId = session.ownerAgentId,
title = commandSessionTitle(session.displayName),
subtitle = if (pendingRunCount > 0) nativeString("Assistant working") else nativeString("OpenClaw session"),
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: nativeString("now"),
@@ -230,6 +231,7 @@ internal fun commandActionAccessibilityDescription(
private data class CommandSessionRow(
val key: String,
val ownerAgentId: String?,
val title: String,
val subtitle: String,
val metadata: String,
@@ -273,11 +275,11 @@ private fun CommandActionRow(row: CommandItem) {
@Composable
private fun CommandSessionList(
rows: List<CommandSessionRow>,
onOpen: (String) -> Unit,
onOpen: (String, String?) -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
ClawSeparatedColumn(items = rows) { row ->
CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
CommandSessionListRow(row = row, onClick = { onOpen(row.key, row.ownerAgentId) })
}
}
}
@@ -60,6 +60,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -84,6 +85,7 @@ internal fun SessionsScreen(
) {
val sessions by viewModel.chatSessions.collectAsState()
val chatSessionKey by viewModel.chatSessionKey.collectAsState()
val activeGatewayStableId by viewModel.activeGatewayStableId.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val coroutineScope = rememberCoroutineScope()
val searchFocusRequester = remember { FocusRequester() }
@@ -92,9 +94,12 @@ internal fun SessionsScreen(
var compactLayout by rememberSaveable { mutableStateOf(false) }
var recentFirst by rememberSaveable { mutableStateOf(true) }
var sortMenuExpanded by remember { mutableStateOf(false) }
var renameSessionKey by rememberSaveable { mutableStateOf<String?>(null) }
var groupSessionKey by rememberSaveable { mutableStateOf<String?>(null) }
var deleteSessionKey by rememberSaveable { mutableStateOf<String?>(null) }
var renameSessionTarget by
rememberSaveable(stateSaver = SessionActionTargetSaver) { mutableStateOf<SessionActionTarget?>(null) }
var groupSessionTarget by
rememberSaveable(stateSaver = SessionActionTargetSaver) { mutableStateOf<SessionActionTarget?>(null) }
var deleteSessionTarget by
rememberSaveable(stateSaver = SessionActionTargetSaver) { mutableStateOf<SessionActionTarget?>(null) }
var searchText by rememberSaveable { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<ChatSessionEntry>>(emptyList()) }
var searchLoading by remember { mutableStateOf(false) }
@@ -127,6 +132,12 @@ internal fun SessionsScreen(
.distinctBy { it.lowercase() }
.sortedWith(String.CASE_INSENSITIVE_ORDER)
LaunchedEffect(activeGatewayStableId) {
renameSessionTarget = renameSessionTarget?.takeIf { it.matchesGateway(activeGatewayStableId) }
groupSessionTarget = groupSessionTarget?.takeIf { it.matchesGateway(activeGatewayStableId) }
deleteSessionTarget = deleteSessionTarget?.takeIf { it.matchesGateway(activeGatewayStableId) }
}
LaunchedEffect(isConnected, filter) {
if (isConnected) {
viewModel.refreshChatSessions(limit = 200, archived = filter == SessionFilter.Archived)
@@ -335,35 +346,45 @@ internal fun SessionsScreen(
archived = session.archived == true,
categories = categories,
onClick = {
viewModel.switchChatSession(session.key)
viewModel.switchChatSession(session.key, session.ownerAgentId)
onOpenChat()
},
onSetPinned = { pinned ->
coroutineScope.launch { viewModel.patchChatSession(key = session.key, pinned = pinned) }
coroutineScope.launch {
viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, pinned = pinned)
}
},
onSetUnread = { unread ->
coroutineScope.launch { viewModel.patchChatSession(key = session.key, unread = unread) }
coroutineScope.launch {
viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, unread = unread)
}
},
onRename = { renameSessionKey = session.key },
onRename = { renameSessionTarget = session.toActionTarget(activeGatewayStableId) },
onFork = {
coroutineScope.launch {
viewModel.forkChatSession(session.key)?.let { newKey ->
viewModel.switchChatSession(newKey)
viewModel.forkChatSession(session.key, session.ownerAgentId)?.let { newKey ->
viewModel.switchChatSession(newKey, session.ownerAgentId)
onOpenChat()
}
}
},
onMoveToGroup = { category ->
coroutineScope.launch { viewModel.patchChatSession(key = session.key, category = category) }
coroutineScope.launch {
viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, category = category)
}
},
onNewGroup = { groupSessionKey = session.key },
onNewGroup = { groupSessionTarget = session.toActionTarget(activeGatewayStableId) },
onRemoveFromGroup = {
coroutineScope.launch { viewModel.patchChatSession(key = session.key, clearCategory = true) }
coroutineScope.launch {
viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, clearCategory = true)
}
},
onSetArchived = { archived ->
coroutineScope.launch { viewModel.patchChatSession(key = session.key, archived = archived) }
coroutineScope.launch {
viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, archived = archived)
}
},
onDelete = { deleteSessionKey = session.key },
onDelete = { deleteSessionTarget = session.toActionTarget(activeGatewayStableId) },
)
}
}
@@ -371,20 +392,22 @@ internal fun SessionsScreen(
}
}
sessions.firstOrNull { it.key == renameSessionKey }?.let { session ->
renameSessionTarget?.let { session ->
SessionTextDialog(
title = nativeString("Rename session"),
stateKey = session.key,
stateKey = session.stateKey,
initialValue = session.label ?: session.displayName.orEmpty(),
confirmLabel = nativeString("Rename"),
allowEmpty = true,
onDismiss = { renameSessionKey = null },
onDismiss = { renameSessionTarget = null },
onConfirm = { value ->
renameSessionKey = null
renameSessionTarget = null
if (!session.matchesGateway(activeGatewayStableId)) return@SessionTextDialog
val label = value.trim()
coroutineScope.launch {
viewModel.patchChatSession(
key = session.key,
ownerAgentId = session.ownerAgentId,
label = label.takeIf(String::isNotEmpty),
clearLabel = label.isEmpty(),
)
@@ -393,19 +416,22 @@ internal fun SessionsScreen(
)
}
sessions.firstOrNull { it.key == groupSessionKey }?.let { session ->
groupSessionTarget?.let { session ->
SessionTextDialog(
title = nativeString("New group"),
stateKey = session.key,
stateKey = session.stateKey,
initialValue = "",
confirmLabel = nativeString("Create"),
allowEmpty = false,
onDismiss = { groupSessionKey = null },
onDismiss = { groupSessionTarget = null },
onConfirm = { value ->
groupSessionKey = null
groupSessionTarget = null
if (!session.matchesGateway(activeGatewayStableId)) return@SessionTextDialog
// Remember the name so the group survives locally even if the patch later empties it.
viewModel.addChatSessionGroup(value)
coroutineScope.launch { viewModel.patchChatSession(key = session.key, category = value.trim()) }
coroutineScope.launch {
viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, category = value.trim())
}
},
)
}
@@ -467,24 +493,25 @@ internal fun SessionsScreen(
)
}
sessions.firstOrNull { it.key == deleteSessionKey }?.let { session ->
deleteSessionTarget?.let { session ->
AlertDialog(
onDismissRequest = { deleteSessionKey = null },
onDismissRequest = { deleteSessionTarget = null },
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text(nativeString("Delete session?"), style = ClawTheme.type.section, color = ClawTheme.colors.text) },
text = { Text(nativeString("This permanently deletes the session and its transcript."), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) },
confirmButton = {
TextButton(
onClick = {
deleteSessionKey = null
coroutineScope.launch { viewModel.deleteChatSession(session.key) }
deleteSessionTarget = null
if (!session.matchesGateway(activeGatewayStableId)) return@TextButton
coroutineScope.launch { viewModel.deleteChatSession(session.key, session.ownerAgentId) }
},
) {
Text(nativeString("Delete"), color = ClawTheme.colors.danger)
}
},
dismissButton = {
TextButton(onClick = { deleteSessionKey = null }) {
TextButton(onClick = { deleteSessionTarget = null }) {
Text(nativeString("Cancel"))
}
},
@@ -826,6 +853,60 @@ internal data class SessionSection(
val isCategory: Boolean = false,
)
/** Immutable row identity retained while a destructive or mutating dialog is open. */
internal data class SessionActionTarget(
val gatewayStableId: String?,
val key: String,
val ownerAgentId: String?,
val label: String?,
val displayName: String?,
) {
val stateKey: String = "${gatewayStableId.orEmpty()}:${ownerAgentId.orEmpty()}:$key"
fun matchesGateway(activeGatewayStableId: String?): Boolean = gatewayStableId == activeGatewayStableId
}
private const val SESSION_ACTION_TARGET_STATE_FIELDS = 9
private val SessionActionTargetSaver =
Saver<SessionActionTarget?, ArrayList<String>>(
save = { target -> target?.toSavedState() ?: arrayListOf() },
restore = ::sessionActionTargetFromSavedState,
)
internal fun SessionActionTarget.toSavedState(): ArrayList<String> =
arrayListOf(
if (gatewayStableId == null) "0" else "1",
gatewayStableId.orEmpty(),
key,
if (ownerAgentId == null) "0" else "1",
ownerAgentId.orEmpty(),
if (label == null) "0" else "1",
label.orEmpty(),
if (displayName == null) "0" else "1",
displayName.orEmpty(),
)
internal fun sessionActionTargetFromSavedState(values: List<String>): SessionActionTarget? {
if (values.size != SESSION_ACTION_TARGET_STATE_FIELDS || values[2].isEmpty()) return null
return SessionActionTarget(
gatewayStableId = values[1].takeIf { values[0] == "1" },
key = values[2],
ownerAgentId = values[4].takeIf { values[3] == "1" },
label = values[6].takeIf { values[5] == "1" },
displayName = values[8].takeIf { values[7] == "1" },
)
}
internal fun ChatSessionEntry.toActionTarget(gatewayStableId: String?): SessionActionTarget =
SessionActionTarget(
gatewayStableId = gatewayStableId,
key = key,
ownerAgentId = ownerAgentId,
label = label,
displayName = displayName,
)
/** Groups pinned sessions once, followed by alphabetical categories and remaining sessions. */
internal fun groupSessionEntries(
entries: List<ChatSessionEntry>,
@@ -312,8 +312,8 @@ fun ShellScreen(
nav.openSettingsRoute(SettingsRoute.Home)
commandOpen = false
},
onOpenSession = { sessionKey ->
viewModel.switchChatSession(sessionKey)
onOpenSession = { sessionKey, ownerAgentId ->
viewModel.switchChatSession(sessionKey, ownerAgentId)
nav.selectTab(Tab.Chat)
commandOpen = false
},
@@ -563,8 +563,8 @@ private fun OverviewScreen(
item {
RecentSessionList(
rows = visibleRecentRows,
onOpen = { sessionKey ->
viewModel.switchChatSession(sessionKey)
onOpen = { sessionKey, ownerAgentId ->
viewModel.switchChatSession(sessionKey, ownerAgentId)
onSelectTab(Tab.Chat)
},
)
@@ -1389,6 +1389,7 @@ internal data class RecentSessionListItem(
val title: String,
val source: String,
val metadata: String,
val ownerAgentId: String? = null,
)
internal fun overviewRecentSessionRows(
@@ -1401,6 +1402,7 @@ internal fun overviewRecentSessionRows(
val title = displaySessionTitle(session.displayName)
RecentSessionListItem(
key = session.key,
ownerAgentId = session.ownerAgentId,
title = title,
source = sessionSourceLabel(session.key, channelsSummary),
metadata = (session.lastActivityAt ?: session.updatedAtMs)?.let(::overviewRelativeSessionTime) ?: "",
@@ -1429,7 +1431,7 @@ private fun RecentSessionListItem.withStableFieldsFrom(previousRow: RecentSessio
@Composable
private fun RecentSessionList(
rows: List<RecentSessionListItem>,
onOpen: (String) -> Unit,
onOpen: (String, String?) -> Unit,
) {
OverviewLayeredPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
Column {
@@ -1438,7 +1440,7 @@ private fun RecentSessionList(
title = row.title,
source = row.source,
metadata = row.metadata,
onClick = { onOpen(row.key) },
onClick = { onOpen(row.key, row.ownerAgentId) },
)
if (index != rows.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border.copy(alpha = 0.48f), thickness = 1.dp)
@@ -3,15 +3,413 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.ChatDraft
import ai.openclaw.app.ChatDraftPlacement
import ai.openclaw.app.ChatShareDraft
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
import ai.openclaw.app.chat.VoiceNoteRecorderState
import android.net.Uri
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.saveable.listSaver
import kotlinx.coroutines.CancellationException
internal const val CHAT_COMPOSER_MAX_DRAFT_OWNERS = 16
internal const val CHAT_COMPOSER_DRAFT_SNAPSHOT_MAX_CHARS = 64 * 1024
internal const val CHAT_COMPOSER_MAX_SEND_CHARS = 20_000
private const val CHAT_COMPOSER_DRAFT_SNAPSHOT_FIELDS = 8
private const val CHAT_COMPOSER_DRAFT_RECORD = "draft"
private const val CHAT_COMPOSER_PENDING_SEND_RECORD = "pending-send"
private const val CHAT_COMPOSER_PENDING_SEND_WITHOUT_INPUT_RECORD = "pending-send-without-input"
internal data class PendingChatComposerSend(
val commandId: String,
val owner: ChatComposerOwner,
val inputSnapshot: String?,
)
internal data class ChatComposerSendPayload(
val inputSnapshot: String,
val message: String,
val attachments: List<PendingAttachment>,
)
/** Captures the owner stores at admission time; Compose values may lag a synchronous edit. */
internal fun captureChatComposerSendPayload(
owner: ChatComposerOwner,
textDrafts: ChatComposerTextDraftStore,
attachmentStore: ChatComposerAttachmentStore,
): ChatComposerSendPayload {
val inputSnapshot = textDrafts[owner]
return ChatComposerSendPayload(
inputSnapshot = inputSnapshot,
message = inputSnapshot.trim(),
attachments = attachmentStore.get(owner),
)
}
internal data class ChatComposerDraftSnapshot(
val drafts: Map<ChatComposerOwner, String> = emptyMap(),
val pendingSends: List<PendingChatComposerSend> = emptyList(),
)
internal class ChatComposerTextDraftStore(
initial: ChatComposerDraftSnapshot = ChatComposerDraftSnapshot(),
private val onSnapshotChanged: (ArrayList<String>) -> Unit = {},
) {
private val drafts = mutableStateMapOf<ChatComposerOwner, String>()
private val recency = ArrayDeque<ChatComposerOwner>()
private val pendingSends = LinkedHashMap<String, PendingChatComposerSend>()
init {
initial.drafts.forEach { (owner, text) ->
drafts[owner] = text
recency.addLast(owner)
}
initial.pendingSends.forEach { pending -> pendingSends[pending.commandId] = pending }
}
operator fun get(owner: ChatComposerOwner): String = drafts[owner].orEmpty()
operator fun set(
owner: ChatComposerOwner,
value: String,
) {
recency.remove(owner)
if (value.isEmpty()) {
drafts.remove(owner)
onSnapshotChanged(snapshot())
return
}
if (owner !in drafts) {
while (drafts.size >= CHAT_COMPOSER_MAX_DRAFT_OWNERS) {
drafts.remove(recency.removeFirst())
}
}
drafts[owner] = value
recency.addLast(owner)
onSnapshotChanged(snapshot())
}
fun migrate(
from: ChatComposerOwner,
to: ChatComposerOwner,
) {
if (from == to) return
val draft = drafts.remove(from)
val existing = drafts[to]
var changed = false
pendingSends.toMap().forEach { (commandId, pending) ->
if (pending.owner == from) {
changed = true
pendingSends[commandId] = pending.copy(owner = to)
}
}
if (draft == null) {
if (changed) onSnapshotChanged(snapshot())
return
}
recency.remove(from)
this[to] =
requireNotNull(mergeChatComposerDraftText(existing, draft))
}
/** Resolves every parked alias, including drafts not visited since gateway hello. */
fun migrateMatching(
to: ChatComposerOwner,
mainSessionKey: String,
): Set<ChatComposerOwner> {
val sources =
(recency + pendingSends.values.map(PendingChatComposerSend::owner))
.filterTo(linkedSetOf()) { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) }
sources.forEach { source -> migrate(from = source, to = to) }
return sources
}
/** Checkpoints the pre-send draft with the id that the durable outbox will use. */
fun beginAdmission(
commandId: String,
owner: ChatComposerOwner,
inputSnapshot: String,
): Boolean {
check(commandId !in pendingSends)
if (inputSnapshot.length > CHAT_COMPOSER_MAX_SEND_CHARS) return false
val pending = PendingChatComposerSend(commandId, owner, inputSnapshot)
val checkpointChars =
(pendingSends.values + pending).sumOf { pendingSendCheckpointEntry(it, includeInput = true).sumOf(String::length) }
if (checkpointChars > CHAT_COMPOSER_DRAFT_SNAPSHOT_MAX_CHARS) return false
pendingSends[commandId] = pending
if (drafts[owner] == inputSnapshot) {
drafts.remove(owner)
recency.remove(owner)
}
onSnapshotChanged(snapshot())
return true
}
/** Resolves one live or process-restored send without clearing text edited after admission. */
fun resolveAdmission(
commandId: String,
admitted: Boolean,
): PendingChatComposerSend? {
val pending = pendingSends.remove(commandId) ?: return null
val current = drafts[pending.owner]
if (!admitted) {
val restored = mergeChatComposerDraftText(pending.inputSnapshot, current)
if (restored != null) {
drafts[pending.owner] = restored
recency.remove(pending.owner)
recency.addLast(pending.owner)
}
}
onSnapshotChanged(snapshot())
return pending
}
fun pendingAdmissions(): List<PendingChatComposerSend> = pendingSends.values.toList()
fun pendingAdmission(commandId: String): PendingChatComposerSend? = pendingSends[commandId]
fun removeOwners(matches: (ChatComposerOwner) -> Boolean) {
val owners = drafts.keys.filterTo(linkedSetOf(), matches)
val removedPending = pendingSends.entries.removeAll { matches(it.value.owner) }
if (owners.isEmpty() && !removedPending) return
owners.forEach(drafts::remove)
recency.removeAll(owners::contains)
onSnapshotChanged(snapshot())
}
internal fun snapshot(): ArrayList<String> {
var remainingChars = CHAT_COMPOSER_DRAFT_SNAPSHOT_MAX_CHARS
val records = mutableListOf<List<String>>()
// Pending ids are the crash-consistency boundary. Always checkpoint the marker; keep its
// draft too when it fits, so restart can restore it only after proving no outbox row exists.
pendingSends.values.forEach { pending ->
val fullEntry = pendingSendCheckpointEntry(pending, includeInput = true)
val entry =
if (fullEntry.sumOf(String::length) <= remainingChars) {
fullEntry
} else {
pendingSendCheckpointEntry(pending, includeInput = false)
}
records += entry
remainingChars -= entry.sumOf(String::length)
}
val retainedNewestFirst = mutableListOf<List<String>>()
// SavedStateHandle is written into the Activity transaction. Keep the full in-memory drafts,
// but checkpoint only the newest complete entries that fit the bounded process-death budget.
for (owner in recency.reversed()) {
val text = drafts.getValue(owner)
val entry = listOf(CHAT_COMPOSER_DRAFT_RECORD) + owner.toCheckpointValues() + listOf("", text)
val entryChars = entry.sumOf(String::length)
if (entryChars > remainingChars) continue
retainedNewestFirst += entry
remainingChars -= entryChars
}
records += retainedNewestFirst.asReversed()
return ArrayList(records.flatten())
}
internal fun size(): Int = drafts.size
}
private fun pendingSendCheckpointEntry(
pending: PendingChatComposerSend,
includeInput: Boolean,
): List<String> =
listOf(if (includeInput) CHAT_COMPOSER_PENDING_SEND_RECORD else CHAT_COMPOSER_PENDING_SEND_WITHOUT_INPUT_RECORD) +
pending.owner.toCheckpointValues() +
listOf(pending.commandId, if (includeInput) pending.inputSnapshot.orEmpty() else "")
internal fun chatComposerTextDraftsFromSnapshot(values: List<String>?): ChatComposerDraftSnapshot {
if (values == null || values.size % CHAT_COMPOSER_DRAFT_SNAPSHOT_FIELDS != 0) {
return ChatComposerDraftSnapshot()
}
val restored = LinkedHashMap<ChatComposerOwner, String>()
val pending = mutableListOf<PendingChatComposerSend>()
values.chunked(CHAT_COMPOSER_DRAFT_SNAPSHOT_FIELDS).forEach { entry ->
val owner = chatComposerOwnerFromCheckpointValues(entry.subList(1, 6)) ?: return@forEach
when (entry[0]) {
CHAT_COMPOSER_DRAFT_RECORD -> if (entry[7].isNotEmpty()) restored[owner] = entry[7]
CHAT_COMPOSER_PENDING_SEND_RECORD -> {
if (entry[6].isNotEmpty()) {
pending += PendingChatComposerSend(entry[6], owner, entry[7])
}
}
CHAT_COMPOSER_PENDING_SEND_WITHOUT_INPUT_RECORD -> {
if (entry[6].isNotEmpty()) {
pending += PendingChatComposerSend(entry[6], owner, null)
}
}
}
}
return ChatComposerDraftSnapshot(drafts = restored, pendingSends = pending)
}
private fun mergeChatComposerDraftText(
existing: String?,
incoming: String?,
): String? =
when {
existing.isNullOrEmpty() -> incoming?.takeIf(String::isNotEmpty)
incoming.isNullOrEmpty() || incoming == existing -> existing
else -> "$existing\n\n$incoming"
}
internal fun ChatComposerOwner.matchesSession(
gatewayStableId: String,
agentId: String,
sessionKey: String,
mainSessionKey: String,
): Boolean {
if (this.gatewayStableId != gatewayStableId) return false
val canonicalMain = mainSessionKey.trim().ifEmpty { "main" }
val ownerKey = this.sessionKey.trim().let { if (it == "main") canonicalMain else it }
val deletedKey = sessionKey.trim().let { if (it == "main") canonicalMain else it }
return ownerKey == deletedKey && (this.agentId == agentId || !routingVerified)
}
internal class ChatComposerOwnerCheckpoint(
var owner: ChatComposerOwner? = null,
private var mediaAuthorizationId: String? = null,
) {
fun begin(
owner: ChatComposerOwner,
mediaAuthorizationId: String,
) {
this.owner = owner
this.mediaAuthorizationId = mediaAuthorizationId
}
fun consume(): ChatComposerMediaLease? {
val capturedOwner = owner ?: return null
val capturedAuthorizationId = mediaAuthorizationId ?: return null
return ChatComposerMediaLease(capturedOwner, capturedAuthorizationId).also { clear() }
}
fun clear() {
owner = null
mediaAuthorizationId = null
}
companion object {
val Saver =
listSaver<ChatComposerOwnerCheckpoint, String>(
save = { checkpoint ->
val capturedOwner = checkpoint.owner
val capturedAuthorizationId = checkpoint.mediaAuthorizationId
if (capturedOwner == null || capturedAuthorizationId == null) {
emptyList()
} else {
capturedOwner.toCheckpointValues() + capturedAuthorizationId
}
},
restore = { values ->
ChatComposerOwnerCheckpoint(
owner = chatComposerOwnerFromCheckpointValues(values.take(5)),
mediaAuthorizationId = values.getOrNull(5),
)
},
)
}
}
internal data class ChatComposerMediaLease(
val owner: ChatComposerOwner,
val authorizationId: String,
)
/** Binds an asynchronous voice-note preparation to the recording that started it. */
internal class ChatVoiceNoteCommitCheckpoint {
var owner: ChatComposerOwner? = null
private var recordingId: String? = null
private var mediaAuthorizationId: String? = null
fun begin(
owner: ChatComposerOwner,
recordingId: String,
mediaAuthorizationId: String,
) {
this.owner = owner
this.recordingId = recordingId
this.mediaAuthorizationId = mediaAuthorizationId
}
fun consume(recordingId: String): ChatComposerMediaLease? {
if (this.recordingId != recordingId) return null
val capturedOwner = owner ?: return null
val capturedAuthorizationId = mediaAuthorizationId ?: return null
return ChatComposerMediaLease(capturedOwner, capturedAuthorizationId).also { clear() }
}
fun clear(): ChatComposerMediaLease? {
val lease =
owner?.let { capturedOwner ->
mediaAuthorizationId?.let { capturedAuthorizationId ->
ChatComposerMediaLease(capturedOwner, capturedAuthorizationId)
}
}
owner = null
recordingId = null
mediaAuthorizationId = null
return lease
}
}
internal fun ChatComposerOwner.toCheckpointValues(): List<String> =
listOf(
if (gatewayStableId == null) "0" else "1",
gatewayStableId.orEmpty(),
agentId,
sessionKey,
if (routingVerified) "1" else "0",
)
internal fun chatComposerOwnerFromCheckpointValues(values: List<String>): ChatComposerOwner? {
if (values.size != 5) return null
return ChatComposerOwner(
gatewayStableId = values[1].takeIf { values[0] == "1" },
agentId = values[2],
sessionKey = values[3],
routingVerified = values[4] == "1",
)
}
internal fun shouldMigrateComposerDraft(
previous: ChatComposerOwner?,
current: ChatComposerOwner,
mainSessionKey: String,
): Boolean {
if (previous == null || previous == current) return false
val canonicalMain = mainSessionKey.trim()
val mainAliasResolved =
previous.sessionKey == "main" &&
canonicalMain != "main" &&
current.sessionKey == canonicalMain
val unboundGatewayClaimed = previous.gatewayStableId == null && current.gatewayStableId != null
if (previous.gatewayStableId != current.gatewayStableId && !unboundGatewayClaimed) return false
// Content captured before gateway startup belongs to the gateway the user subsequently
// selects once routing is verified. Session identity still must match; an unverified agent
// id is only a placeholder and cannot claim the draft for another gateway.
if (unboundGatewayClaimed) {
if (!current.routingVerified) return false
if (previous.routingVerified && previous.agentId != current.agentId) return false
return previous.sessionKey == current.sessionKey || mainAliasResolved
}
if (!previous.routingVerified && current.routingVerified) {
return previous.sessionKey == current.sessionKey || mainAliasResolved
}
return previous.agentId == current.agentId && mainAliasResolved
}
internal fun canCommitComposerResult(
ownerSnapshot: ChatComposerOwner,
currentOwner: ChatComposerOwner,
): Boolean = ownerSnapshot == currentOwner
internal fun mergeChatDraft(
draft: ChatDraft?,
currentInput: String,
currentOwner: ChatComposerOwner? = null,
): String? {
if (draft?.owner != null && draft.owner != currentOwner) return null
val text = draft?.text?.takeIf { it.isNotBlank() } ?: return null
return when (draft.placement) {
ChatDraftPlacement.Replace -> text
@@ -38,6 +436,9 @@ internal data class StagedChatShare(
internal const val CHAT_COMPOSER_MAX_ATTACHMENTS = 8
internal const val CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES = OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
internal const val CHAT_COMPOSER_MAX_BASE64_CHARS = ((CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES + 2) / 3) * 4
internal const val CHAT_COMPOSER_MAX_TOTAL_ATTACHMENTS = 24
internal const val CHAT_COMPOSER_MAX_TOTAL_DECODED_ATTACHMENT_BYTES = CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES * 3
internal const val CHAT_COMPOSER_MAX_TOTAL_BASE64_CHARS = ((CHAT_COMPOSER_MAX_TOTAL_DECODED_ATTACHMENT_BYTES + 2) / 3) * 4
internal data class ChatAttachmentAdmission(
val accepted: List<PendingAttachment>,
@@ -73,7 +474,7 @@ internal fun admitChatAttachments(
return ChatAttachmentAdmission(accepted = accepted, omittedCount = omittedCount)
}
private fun decodedBase64ByteCount(base64: String): Long {
internal fun decodedBase64ByteCount(base64: String): Long {
val padding =
when {
base64.endsWith("==") -> 2
@@ -86,7 +487,6 @@ private fun decodedBase64ByteCount(base64: String): Long {
/** Loads a complete queue head before any part of it becomes visible in the composer. */
internal suspend fun stageChatShareDraft(
draft: ChatShareDraft,
currentAttachments: List<PendingAttachment>,
loadImage: suspend (Uri) -> PendingAttachment,
): StagedChatShare {
val attachments = mutableListOf<PendingAttachment>()
@@ -95,7 +495,7 @@ internal suspend fun stageChatShareDraft(
for (uri in draft.imageUris) {
try {
val candidate = loadImage(uri)
val admission = admitChatAttachments(currentAttachments + attachments, listOf(candidate))
val admission = admitChatAttachments(attachments, listOf(candidate))
attachments += admission.accepted
droppedImageCount += admission.omittedCount
} catch (error: CancellationException) {
@@ -113,39 +513,24 @@ internal suspend fun stageChatShareDraft(
)
}
internal data class ChatShareComposerMerge(
val input: String,
val attachments: List<PendingAttachment>,
val failedImageCount: Int,
val droppedImageCount: Int,
)
internal fun mergeStagedChatShare(
staged: StagedChatShare,
currentInput: String,
currentAttachments: List<PendingAttachment>,
): ChatShareComposerMerge {
val admission = admitChatAttachments(currentAttachments, staged.attachments)
return ChatShareComposerMerge(
input = mergeSharedChatText(sharedText = staged.text, currentInput = currentInput),
attachments = currentAttachments + admission.accepted,
failedImageCount = staged.failedImageCount,
droppedImageCount = staged.droppedImageCount + admission.omittedCount,
)
}
internal fun canCommitStagedChatShare(
stagedId: Long,
currentHead: ChatShareDraft?,
): Boolean = currentHead?.id == stagedId
ownerSnapshot: ChatComposerOwner,
currentOwner: ChatComposerOwner,
): Boolean =
currentHead?.id == stagedId &&
canCommitComposerResult(ownerSnapshot = ownerSnapshot, currentOwner = currentOwner)
internal fun chatComposerSendEnabled(
voiceNoteState: VoiceNoteRecorderState,
pendingRunCount: Int,
hasContent: Boolean,
shareStaging: Boolean,
sendInFlight: Boolean = false,
): Boolean =
!shareStaging &&
!sendInFlight &&
voiceNoteState !is VoiceNoteRecorderState.Recording &&
voiceNoteState !is VoiceNoteRecorderState.Preparing &&
pendingRunCount == 0 &&
@@ -426,6 +426,7 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
@Composable
fun ChatOutboxBubble(
item: ChatOutboxItem,
retryEnabled: Boolean = true,
onRetry: () -> Unit,
onDelete: () -> Unit,
) {
@@ -467,7 +468,7 @@ fun ChatOutboxBubble(
color = statusColor,
modifier = Modifier.weight(1f),
)
if (failed) {
if (failed && retryEnabled) {
ChatOutboxAction(label = nativeString("Retry"), color = mobileAccent, onClick = onRetry)
}
// Sending rows are mid-dispatch and accepted rows may already be delivered; both stay
@@ -1,10 +1,13 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.ChatComposerSendStartResult
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.PendingAssistantAutoSend
import ai.openclaw.app.R
import ai.openclaw.app.chat.ChatCommandEntry
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatOutboxItem
@@ -18,11 +21,16 @@ import ai.openclaw.app.chat.ChatWidgetResource
import ai.openclaw.app.chat.MessageSpeechPhase
import ai.openclaw.app.chat.MessageSpeechState
import ai.openclaw.app.chat.VoiceNoteRecorderState
import ai.openclaw.app.chat.chatOutboxQueueFailureText
import ai.openclaw.app.chat.resolveChatComposerOwner
import ai.openclaw.app.chat.resolveGatewayDefaultAgentId
import ai.openclaw.app.currentAppLanguage
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.joinedNativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeTextResource
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
import ai.openclaw.app.ui.copyGatewayDiagnosticsReport
import ai.openclaw.app.ui.design.AgentAvatarSource
@@ -98,10 +106,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -119,23 +127,27 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import kotlin.math.roundToInt
/** Returns a pending assistant prompt only when chat can accept it immediately. */
internal fun resolvePendingAssistantAutoSend(
pendingPrompt: String?,
pending: PendingAssistantAutoSend?,
currentOwner: ChatComposerOwner,
healthOk: Boolean,
pendingRunCount: Int,
): String? {
val prompt = pendingPrompt?.trim()?.ifEmpty { null } ?: return null
): PendingAssistantAutoSend? {
val queued = pending ?: return null
if (queued.prompt.isBlank() || queued.owner != currentOwner) return null
if (!healthOk || pendingRunCount > 0) return null
return prompt
return queued
}
/** Chooses the session key to load for initial chat hydration, if any. */
@@ -163,9 +175,12 @@ fun ChatScreen(
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val activeGatewayStableId by viewModel.activeGatewayStableId.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val sessionOwnerAgentId by viewModel.chatSessionOwnerAgentId.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val gatewayDefaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState()
val gatewayComposerDefaultAgentOwner by viewModel.gatewayComposerDefaultAgentOwner.collectAsState()
val gatewayAgents by viewModel.gatewayAgents.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val thinkingLevelSelection by viewModel.chatThinkingLevelSelection.collectAsState()
@@ -175,7 +190,7 @@ fun ChatScreen(
val sessions by viewModel.chatSessions.collectAsState()
val chatCommands by viewModel.chatCommands.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val chatShareDraft by viewModel.chatShareDraft.collectAsState()
val chatShareDrafts by viewModel.chatShareDrafts.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val assistantAutoSendInFlight by viewModel.assistantAutoSendInFlight.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -203,17 +218,59 @@ fun ChatScreen(
val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() }
val offlineStatus = gatewayStatusForDisplay(gatewayProblemMessage ?: gatewayConnectionDisplay.statusText)
val gatewayOffline = !gatewayConnectionDisplay.isConnected
val sessionAgentId = resolveAgentIdFromMainSessionKey(sessionKey) ?: gatewayDefaultAgentId ?: "main"
val activeAgentId = selectedChatAgentId(mainSessionKey, gatewayDefaultAgentId)
val effectiveGatewayDefaultAgentId =
resolveGatewayDefaultAgentId(activeGatewayStableId, gatewayDefaultAgentId, gatewayComposerDefaultAgentOwner)
val sessionAgentId = resolveAgentIdFromMainSessionKey(sessionKey) ?: sessionOwnerAgentId ?: effectiveGatewayDefaultAgentId ?: "main"
val composerOwner =
resolveChatComposerOwner(
gatewayStableId = activeGatewayStableId,
gatewayDefaultAgentId = sessionOwnerAgentId ?: gatewayDefaultAgentId,
lastVerifiedOwner = if (sessionOwnerAgentId == null) gatewayComposerDefaultAgentOwner else null,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
val activeAgentId = sessionAgentId
val workspaceGit = gatewayAgents.firstOrNull { it.id == sessionAgentId }?.workspaceGit == true
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleState by lifecycleOwner.lifecycle.currentStateFlow.collectAsState()
val resolver = context.contentResolver
val resolver = context.applicationContext.contentResolver
val scope = rememberCoroutineScope()
val attachments = remember { mutableStateListOf<PendingAttachment>() }
val inputDrafts = remember(viewModel) { viewModel.chatComposerTextDrafts }
val imagePickerOwnerCheckpoint =
rememberSaveable(saver = ChatComposerOwnerCheckpoint.Saver) { ChatComposerOwnerCheckpoint() }
val voiceNoteCommitCheckpoint = remember { ChatVoiceNoteCommitCheckpoint() }
val input = inputDrafts[composerOwner]
val attachmentsByOwner by viewModel.chatComposerAttachments.collectAsState()
val attachments = attachmentsByOwner[composerOwner].orEmpty()
val sendOwnersInFlight by viewModel.chatComposerSendOwners.collectAsState()
val sendAdmissions by viewModel.chatComposerSendAdmissions.collectAsState()
val attachmentNotices by viewModel.chatComposerAttachmentNotices.collectAsState()
val shareOwnerRevision by viewModel.chatShareDraftOwnerRevision.collectAsState()
val chatShareDraft =
remember(chatShareDrafts, composerOwner, mainSessionKey, shareOwnerRevision) {
chatShareDrafts.firstOrNull { draft ->
viewModel.chatShareDraftTargetsOwner(draft.id, composerOwner, mainSessionKey)
}
}
val shareStaging =
chatShareDraft?.let { viewModel.chatShareDraftTargetsOwner(it.id, composerOwner, mainSessionKey) } == true
val sendAdmission = sendAdmissions[composerOwner]
val currentPickerOwner by rememberUpdatedState(composerOwner)
val currentPickerMainSessionKey by rememberUpdatedState(mainSessionKey)
val sendInFlight = composerOwner in sendOwnersInFlight
var showModelPicker by rememberSaveable { mutableStateOf(false) }
var showBackgroundTasks by rememberSaveable { mutableStateOf(false) }
var sendMessageTooLong by rememberSaveable(composerOwner) { mutableStateOf(false) }
var sendCheckpointFull by rememberSaveable(composerOwner) { mutableStateOf(false) }
LaunchedEffect(composerOwner, mainSessionKey, chatShareDraft?.id) {
viewModel.resolveChatComposerOwnerAliases(to = composerOwner, mainSessionKey = mainSessionKey)
if (shouldMigrateComposerDraft(voiceNoteCommitCheckpoint.owner, composerOwner, mainSessionKey)) {
voiceNoteCommitCheckpoint.owner = composerOwner
}
viewModel.resolveChatShareDraftOwner(chatShareDraft?.id, composerOwner, mainSessionKey)
}
DisposableEffect(viewModel) {
onDispose(viewModel::stopChatMessageSpeech)
@@ -235,84 +292,142 @@ fun ChatScreen(
val voiceNoteRecorder =
rememberVoiceNoteRecorderController(
viewModel = viewModel,
onFinished = attachments::add,
ownerKey = composerOwner,
mainSessionKey = mainSessionKey,
onFinished = { recordingId, attachment ->
val lease = voiceNoteCommitCheckpoint.consume(recordingId) ?: return@rememberVoiceNoteRecorderController
viewModel.addChatComposerAttachments(lease.owner, lease.authorizationId, listOf(attachment))
},
)
val voiceNoteState by voiceNoteRecorder.state.collectAsState()
val voiceNoteElapsedMs by voiceNoteRecorder.elapsedMs.collectAsState()
val voiceNoteLevel by voiceNoteRecorder.inputLevel.collectAsState()
val pickImages =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
val next =
uris.take(8).mapNotNull { uri ->
val lease = imagePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult
if (uris.isNullOrEmpty()) {
viewModel.cancelChatComposerMediaAcquisition(lease.authorizationId)
return@rememberLauncherForActivityResult
}
val importOwner =
if (shouldMigrateComposerDraft(lease.owner, currentPickerOwner, currentPickerMainSessionKey)) {
currentPickerOwner
} else {
lease.owner
}
val selectedUris = uris.take(8)
viewModel.importChatComposerAttachments(
owner = importOwner,
mediaAuthorizationId = lease.authorizationId,
mainSessionKey = currentPickerMainSessionKey,
expectedCount = uris.size,
) {
selectedUris
.mapNotNull { uri ->
try {
loadSizedImageAttachment(resolver, uri)
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
null
}
}
withContext(Dispatchers.Main) {
attachments.addAll(next)
}
}
}
LaunchedEffect(Unit) {
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
if (loadSessionKey != null) {
viewModel.loadChat(loadSessionKey)
viewModel.loadChat(loadSessionKey, sessionOwnerAgentId)
}
viewModel.refreshChatSessions(limit = 100)
viewModel.refreshChatCommands()
}
LaunchedEffect(pendingAssistantAutoSend, assistantAutoSendInFlight, healthOk, pendingRunCount, thinkingLevel) {
LaunchedEffect(
pendingAssistantAutoSend,
assistantAutoSendInFlight,
sendOwnersInFlight,
composerOwner,
healthOk,
pendingRunCount,
thinkingLevel,
) {
if (!healthOk) return@LaunchedEffect
val prompt =
val pending =
resolvePendingAssistantAutoSend(
pendingPrompt = pendingAssistantAutoSend,
pending = pendingAssistantAutoSend,
currentOwner = composerOwner,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
) ?: return@LaunchedEffect
viewModel.dispatchPendingAssistantAutoSend(
pendingPrompt = prompt,
pending = pending,
thinking = thinkingLevel,
)
}
var input by rememberSaveable { mutableStateOf("") }
var shareImportNoticeVisible by rememberSaveable { mutableStateOf(false) }
val shareImportNotice =
if (shareImportNoticeVisible) {
nativeText("Some shared images were omitted or could not be added.")
} else {
null
when (attachmentNotices[composerOwner]) {
ai.openclaw.app.ChatComposerAttachmentNotice.Attachment ->
NativeText.Resource(source = "Could not stage an attachment for sending.", formatArgs = emptyList())
ai.openclaw.app.ChatComposerAttachmentNotice.Image ->
nativeText("Some shared images were omitted or could not be added.")
null ->
when {
sendMessageTooLong ->
joinedNativeText(
separator = " ",
parts =
listOf(
chatOutboxQueueFailureText(),
verbatimText("${input.length}/$CHAT_COMPOSER_MAX_SEND_CHARS"),
),
)
sendCheckpointFull -> chatOutboxQueueFailureText()
else -> null
}
}
LaunchedEffect(chatDraft) {
input = mergeChatDraft(chatDraft, input) ?: return@LaunchedEffect
viewModel.clearChatDraft()
LaunchedEffect(chatDraft, composerOwner, mainSessionKey) {
val pending = chatDraft ?: return@LaunchedEffect
val claimed =
viewModel.consumeChatDraft(
expected = pending,
owner = composerOwner,
mainSessionKey = mainSessionKey,
) ?: return@LaunchedEffect
inputDrafts[composerOwner] =
mergeChatDraft(draft = claimed, currentInput = input, currentOwner = composerOwner) ?: return@LaunchedEffect
}
LaunchedEffect(chatShareDraft?.id, lifecycleState) {
LaunchedEffect(composerOwner, sendAdmission) {
val admission = sendAdmission ?: return@LaunchedEffect
viewModel.acknowledgeChatComposerSendAdmission(composerOwner, admission.id)
}
// The process queue remembers the first owner; only an explicit alias/identity resolution
// migrates that claim. Navigating elsewhere must never retarget a shared payload.
LaunchedEffect(chatShareDraft?.id, lifecycleState, composerOwner, shareOwnerRevision) {
if (!lifecycleState.isAtLeast(Lifecycle.State.RESUMED)) return@LaunchedEffect
val share = chatShareDraft ?: return@LaunchedEffect
viewModel.withChatShareDraftLease(share.id) {
val attachmentSnapshot = attachments.toList()
val ownerSnapshot = composerOwner
viewModel.withChatShareDraftLease(share.id, ownerSnapshot) {
val staged =
withContext(Dispatchers.IO) {
stageChatShareDraft(share, currentAttachments = attachmentSnapshot) { uri ->
stageChatShareDraft(share) { uri ->
loadSizedImageAttachment(resolver, uri)
}
}
val merged =
mergeStagedChatShare(
staged = staged,
currentInput = input,
currentAttachments = attachments,
if (!viewModel.isCurrentChatComposerOwner(ownerSnapshot)) return@withChatShareDraftLease
if (
!canCommitStagedChatShare(
stagedId = share.id,
currentHead = viewModel.chatShareDraftForOwner(ownerSnapshot, mainSessionKey),
ownerSnapshot = ownerSnapshot,
currentOwner = ownerSnapshot,
)
if (!canCommitStagedChatShare(stagedId = share.id, currentHead = viewModel.chatShareDraft.value)) {
) {
return@withChatShareDraftLease
}
// A non-resumed Activity must not acknowledge into its hidden composer; the next visible
@@ -322,11 +437,14 @@ fun ChatScreen(
}
// Keep the head pending through both mutations: Send stays gated until text and images
// have been merged together, and disposal before this point leaves the head for retry.
input = merged.input
attachments.clear()
attachments.addAll(merged.attachments)
shareImportNoticeVisible = merged.failedImageCount + merged.droppedImageCount > 0
viewModel.acknowledgeChatShareDraft(share.id)
inputDrafts[ownerSnapshot] =
mergeSharedChatText(sharedText = staged.text, currentInput = inputDrafts[ownerSnapshot])
val admissionOmissions = viewModel.addChatComposerAttachments(ownerSnapshot, staged.attachments)
viewModel.reportChatComposerImageOmission(
ownerSnapshot,
staged.failedImageCount + staged.droppedImageCount + admissionOmissions,
)
viewModel.acknowledgeChatShareDraft(share.id, ownerSnapshot)
}
}
@@ -385,8 +503,8 @@ fun ChatScreen(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
onSelectSession = { key ->
viewModel.switchChatSession(key)
onSelectSession = { entry ->
viewModel.switchChatSession(entry.key, entry.ownerAgentId)
viewModel.refreshChatSessions(limit = 100)
},
onOpenSessions = onOpenSessions,
@@ -413,12 +531,17 @@ fun ChatScreen(
items = outboxItems,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
ownerAgentId = composerOwner.agentId,
messages = messages,
),
recoveryOutboxItems =
outboxItemsForRecovery(
items = outboxItems,
),
onRetryOutbox = viewModel::retryChatOutboxCommand,
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
onStarterPrompt = { prompt -> input = prompt },
onReplyMessage = viewModel::setChatReplyDraft,
onStarterPrompt = { prompt -> inputDrafts[composerOwner] = prompt },
onReplyMessage = { value -> viewModel.setChatReplyDraft(value, composerOwner) },
speechState = messageSpeechState,
onToggleListen = viewModel::toggleChatMessageSpeech,
resolveInlineWidgetResource = viewModel::resolveInlineWidgetResource,
@@ -431,7 +554,11 @@ fun ChatScreen(
ChatComposer(
value = input,
onValueChange = { input = it },
onValueChange = {
sendMessageTooLong = false
sendCheckpointFull = false
inputDrafts[composerOwner] = it
},
attachments = attachments,
thinkingLevel = thinkingLevel,
thinkingOptions = thinkingLevelSelection.options,
@@ -443,20 +570,58 @@ fun ChatScreen(
gatewayOffline = gatewayOffline,
offlineStatus = offlineStatus,
pendingRunCount = pendingRunCount,
shareStaging = chatShareDraft != null,
shareStaging = shareStaging,
sendInFlight = sendInFlight,
shareImportNotice = shareImportNotice,
onDismissShareImportNotice = { shareImportNoticeVisible = false },
onDismissShareImportNotice = {
sendMessageTooLong = false
sendCheckpointFull = false
viewModel.clearChatComposerAttachmentOmission(composerOwner)
},
commands = chatCommands,
onThinkingLevelChange = viewModel::setChatThinkingLevel,
onOpenModelPicker = { showModelPicker = true },
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onPickImages = {
if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer
val authorizationId = viewModel.beginChatComposerMediaAcquisition(composerOwner) ?: return@ChatComposer
imagePickerOwnerCheckpoint.begin(composerOwner, authorizationId)
pickImages.launch("image/*")
},
onRemoveAttachment = { id -> viewModel.removeChatComposerAttachments(composerOwner, setOf(id)) },
voiceNoteState = voiceNoteState,
voiceNoteElapsedMs = voiceNoteElapsedMs,
voiceNoteLevel = voiceNoteLevel,
recordVoiceNoteEnabled = pendingRunCount == 0 && !micCaptureActive,
onStartVoiceNote = { scope.launch { voiceNoteRecorder.start() } },
onCancelVoiceNote = voiceNoteRecorder::cancel,
recordVoiceNoteEnabled = pendingRunCount == 0 && !micCaptureActive && !sendInFlight,
onStartVoiceNote = {
scope.launch {
val ownerSnapshot = composerOwner
val mediaAuthorizationId = viewModel.beginChatComposerMediaAcquisition(ownerSnapshot) ?: return@launch
val recordingId = UUID.randomUUID().toString()
if (!viewModel.isCurrentChatComposerOwner(ownerSnapshot)) {
viewModel.cancelChatComposerMediaAcquisition(mediaAuthorizationId)
return@launch
}
if (voiceNoteRecorder.start(recordingId)) {
if (
viewModel.isCurrentChatComposerOwner(ownerSnapshot) &&
viewModel.isChatComposerMediaAcquisitionActive(mediaAuthorizationId)
) {
voiceNoteCommitCheckpoint.begin(ownerSnapshot, recordingId, mediaAuthorizationId)
} else {
voiceNoteRecorder.cancel()
viewModel.cancelChatComposerMediaAcquisition(mediaAuthorizationId)
}
} else {
viewModel.cancelChatComposerMediaAcquisition(mediaAuthorizationId)
}
}
},
onCancelVoiceNote = {
voiceNoteCommitCheckpoint.clear()?.let { lease ->
viewModel.cancelChatComposerMediaAcquisition(lease.authorizationId)
}
voiceNoteRecorder.cancel()
},
onFinishVoiceNote = voiceNoteRecorder::finish,
onVoice = onVoice,
onFixConnection = onOpenGatewaySettings,
@@ -471,23 +636,19 @@ fun ChatScreen(
onAbort = viewModel::abortChat,
onSend = {
// Re-read the ViewModel so a stale click callback cannot beat StateFlow recomposition.
if (viewModel.chatShareDraft.value != null) return@ChatComposer
val message = input.trim()
if (message.isEmpty() && attachments.isEmpty()) return@ChatComposer
shareImportNoticeVisible = false
val outgoing = attachments.map(PendingAttachment::toOutgoingAttachment)
val pendingAttachments = attachments.toList()
input = ""
attachments.clear()
scope.launch {
val accepted = viewModel.sendChatAwaitAcceptance(message = message, thinking = thinkingLevel, attachments = outgoing)
if (!accepted) {
// Refused sends (offline queue full, enqueue failure) must not eat the draft;
// restore it unless the user already started typing something new.
if (input.isEmpty()) input = message
if (attachments.isEmpty()) attachments.addAll(pendingAttachments)
}
val currentShare = viewModel.chatShareDraftForOwner(composerOwner, mainSessionKey)
if (currentShare != null || composerOwner in sendOwnersInFlight) {
return@ChatComposer
}
val ownerSnapshot = composerOwner
if (!viewModel.isCurrentChatComposerOwner(ownerSnapshot)) return@ChatComposer
val result =
viewModel.beginChatComposerSend(
owner = ownerSnapshot,
thinking = thinkingLevel,
)
sendMessageTooLong = result == ChatComposerSendStartResult.MessageTooLong
sendCheckpointFull = result == ChatComposerSendStartResult.CheckpointFull
},
)
}
@@ -542,7 +703,7 @@ private fun ChatSessionSwitcher(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
onSelectSession: (String) -> Unit,
onSelectSession: (ChatSessionEntry) -> Unit,
onOpenSessions: () -> Unit,
) {
val allChoices =
@@ -580,7 +741,7 @@ private fun ChatSessionSwitcher(
ChatSessionChip(
text = chatSessionChipText(entry = entry, mainSessionKey = mainSessionKey),
active = isActiveSessionChoice(entry.key, sessionKey, mainSessionKey),
onClick = { onSelectSession(entry.key) },
onClick = { onSelectSession(entry) },
)
}
if (hasMoreSessions) {
@@ -806,6 +967,7 @@ private fun ChatMessageList(
healthOk: Boolean,
gatewayOffline: Boolean,
outboxItems: List<ChatOutboxItem>,
recoveryOutboxItems: List<ChatOutboxItem>,
onRetryOutbox: (String) -> Unit,
onDeleteOutbox: (String) -> Unit,
onStarterPrompt: (String) -> Unit,
@@ -816,13 +978,14 @@ private fun ChatMessageList(
modifier: Modifier = Modifier,
) {
val timeline =
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText, outboxItems) {
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText, outboxItems, recoveryOutboxItems) {
buildChatTimeline(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
outboxItems = outboxItems,
recoveryOutboxItems = recoveryOutboxItems,
)
}
val readerScroll =
@@ -861,6 +1024,22 @@ private fun ChatMessageList(
onRetry = { onRetryOutbox(item.item.id) },
onDelete = { onDeleteOutbox(item.item.id) },
)
is ChatTimelineItem.RecoveryOutboxCommand ->
ChatOutboxBubble(
item = item.item,
retryEnabled = false,
onRetry = { onRetryOutbox(item.item.id) },
onDelete = { onDeleteOutbox(item.item.id) },
)
is ChatTimelineItem.OutboxRecoveryHeader ->
ChatNotice(
title = nativeString("Messages to recover"),
body =
nativeString(
"\${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.",
item.count,
),
)
is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls)
is ChatTimelineItem.StreamingAssistant ->
ChatBubble(
@@ -1359,6 +1538,7 @@ private fun ChatComposer(
offlineStatus: String,
pendingRunCount: Int,
shareStaging: Boolean,
sendInFlight: Boolean,
shareImportNotice: NativeText?,
onDismissShareImportNotice: () -> Unit,
commands: List<ChatCommandEntry>,
@@ -1396,6 +1576,7 @@ private fun ChatComposer(
pendingRunCount = pendingRunCount,
hasContent = value.trim().isNotEmpty() || attachments.isNotEmpty(),
shareStaging = shareStaging,
sendInFlight = sendInFlight,
)
Column(modifier = Modifier.fillMaxWidth().imePadding(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -2,7 +2,10 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatOutboxItem
import ai.openclaw.app.chat.ChatOutboxStatus
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.OUTBOX_OWNER_CHANGED_ERROR
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
internal sealed class ChatTimelineItem {
data class Message(
@@ -14,6 +17,15 @@ internal sealed class ChatTimelineItem {
val item: ChatOutboxItem,
) : ChatTimelineItem()
/** Gateway-level recovery row that cannot be placed in the visible owner/session. */
data class RecoveryOutboxCommand(
val item: ChatOutboxItem,
) : ChatTimelineItem()
data class OutboxRecoveryHeader(
val count: Int,
) : ChatTimelineItem()
data class StreamingAssistant(
val text: String,
) : ChatTimelineItem()
@@ -40,12 +52,15 @@ internal fun buildChatTimeline(
pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?,
outboxItems: List<ChatOutboxItem> = emptyList(),
recoveryOutboxItems: List<ChatOutboxItem> = emptyList(),
): ChatTimeline {
val stream = streamingAssistantText?.trim()?.takeIf { it.isNotEmpty() }
val items =
buildList {
// reverseLayout: index 0 renders bottom-most; queued commands are the newest user input.
outboxItems.asReversed().forEach { item -> add(ChatTimelineItem.OutboxCommand(item)) }
recoveryOutboxItems.asReversed().forEach { item -> add(ChatTimelineItem.RecoveryOutboxCommand(item)) }
if (recoveryOutboxItems.isNotEmpty()) add(ChatTimelineItem.OutboxRecoveryHeader(recoveryOutboxItems.size))
if (stream != null) add(ChatTimelineItem.StreamingAssistant(stream))
if (pendingToolCalls.isNotEmpty()) add(ChatTimelineItem.PendingTools(pendingToolCalls))
if (pendingRunCount > 0) add(ChatTimelineItem.Thinking)
@@ -83,20 +98,24 @@ internal fun buildChatTimeline(
latestContentIndex = latestContentIndex,
latestUserMessageId = latestUserMessage?.id,
latestUserMessageVersion = latestUserMessage?.let(::stableMessageVersion),
latestContentVersion = latestContentVersion(messages, pendingRunCount, pendingToolCalls, stream, outboxItems),
latestContentVersion =
latestContentVersion(messages, pendingRunCount, pendingToolCalls, stream, outboxItems + recoveryOutboxItems),
)
}
/**
* Outbox rows for the visible session. Rows enqueued under the "main" alias still belong to the
* Outbox rows for the visible session owner. Rows enqueued under the "main" alias still belong to the
* canonical main session once the gateway hello rewrites the current key. Rows whose user turn
* is already visible as a message (optimistic while a live run owns it, or the canonical history
* copy right before the row retires) are hidden so one send never renders as two bubbles.
* copy right before the row retires) are hidden so one send never renders as two bubbles. Migrated
* ownerless and unreachable legacy-main rows are excluded here and rendered only in the
* gateway-level recovery section.
*/
internal fun outboxItemsForSession(
items: List<ChatOutboxItem>,
sessionKey: String,
mainSessionKey: String,
ownerAgentId: String,
messages: List<ChatMessage> = emptyList(),
): List<ChatOutboxItem> {
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
@@ -107,10 +126,28 @@ internal fun outboxItemsForSession(
.toSet()
return items.filter { item ->
val itemKey = item.sessionKey.let { if (it == "main") mainKey else it }
itemKey == current && "${item.id}:user" !in visibleUserKeys
val ownerMatches = item.ownerAgentId == ownerAgentId
ownerMatches &&
itemKey == current &&
"${item.id}:user" !in visibleUserKeys &&
!isRecoveryOutboxItem(item)
}
}
/** Rows with missing or internally contradictory ownership still need neutral controls. */
internal fun outboxItemsForRecovery(items: List<ChatOutboxItem>): List<ChatOutboxItem> = items.filter(::isRecoveryOutboxItem)
private fun isRecoveryOutboxItem(item: ChatOutboxItem): Boolean {
val keyOwner = resolveAgentIdFromMainSessionKey(item.sessionKey)
val parkedMainAlias =
item.sessionKey.trim() == "main" &&
item.status == ChatOutboxStatus.Failed &&
item.lastError == OUTBOX_OWNER_CHANGED_ERROR
return item.ownerAgentId == null ||
(keyOwner != null && keyOwner != item.ownerAgentId) ||
parkedMainAlias
}
private fun stableMessageVersion(message: ChatMessage): String {
val role = message.role.trim().lowercase()
val idempotencyKey = message.idempotencyKey?.trim().orEmpty()
@@ -202,6 +239,8 @@ internal fun chatTimelineItemKey(item: ChatTimelineItem): String =
when (item) {
is ChatTimelineItem.Message -> "message:${item.message.id}"
is ChatTimelineItem.OutboxCommand -> "outbox:${item.item.id}"
is ChatTimelineItem.RecoveryOutboxCommand -> "outbox-recovery:${item.item.id}"
is ChatTimelineItem.OutboxRecoveryHeader -> "outbox-recovery-header"
is ChatTimelineItem.PendingTools -> "tools"
is ChatTimelineItem.StreamingAssistant -> "stream"
ChatTimelineItem.Thinking -> "thinking"
@@ -1,9 +1,14 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.chat.VOICE_NOTE_MIME_TYPE
import ai.openclaw.app.chat.VoiceNoteRecording
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Base64
import java.util.concurrent.atomic.AtomicLong
/** Attachment staged in a composer until the next chat.send call. */
data class PendingAttachment(
@@ -14,6 +19,158 @@ data class PendingAttachment(
val durationMs: Long? = null,
)
internal data class ChatComposerAttachmentMigration(
val sources: Set<ChatComposerOwner>,
val omittedCount: Int,
)
/** ViewModel-owned heap state; attachment payloads are too large for Android saved state. */
internal class ChatComposerAttachmentStore(
private val maxTotalAttachmentCount: Int = CHAT_COMPOSER_MAX_TOTAL_ATTACHMENTS,
private val maxTotalBase64Chars: Long = CHAT_COMPOSER_MAX_TOTAL_BASE64_CHARS,
private val maxTotalDecodedBytes: Long = CHAT_COMPOSER_MAX_TOTAL_DECODED_ATTACHMENT_BYTES,
) {
init {
require(maxTotalAttachmentCount >= 0 && maxTotalBase64Chars >= 0 && maxTotalDecodedBytes >= 0)
}
private val lock = Any()
private val importSequence = AtomicLong()
private val importOwners = mutableMapOf<Long, ChatComposerOwner>()
private val _attachments = MutableStateFlow<Map<ChatComposerOwner, List<PendingAttachment>>>(emptyMap())
val attachments: StateFlow<Map<ChatComposerOwner, List<PendingAttachment>>> = _attachments.asStateFlow()
fun add(
owner: ChatComposerOwner,
candidates: List<PendingAttachment>,
): Int =
synchronized(lock) {
addLocked(owner, candidates)
}
fun beginImport(owner: ChatComposerOwner): Long =
synchronized(lock) {
importSequence.incrementAndGet().also { importOwners[it] = owner }
}
fun completeImport(
id: Long,
candidates: List<PendingAttachment>,
): Pair<ChatComposerOwner, Int>? =
synchronized(lock) {
val owner = importOwners.remove(id) ?: return@synchronized null
owner to addLocked(owner, candidates)
}
fun cancelImport(id: Long) {
synchronized(lock) {
importOwners.remove(id)
}
}
fun remove(
owner: ChatComposerOwner,
ids: Set<String>,
) {
if (ids.isEmpty()) return
synchronized(lock) {
replaceLocked(owner, _attachments.value[owner].orEmpty().filterNot { it.id in ids })
}
}
fun removeOwners(matches: (ChatComposerOwner) -> Boolean) {
synchronized(lock) {
importOwners.entries.removeAll { matches(it.value) }
_attachments.value = _attachments.value.filterKeys { !matches(it) }
}
}
fun migrate(
from: ChatComposerOwner,
to: ChatComposerOwner,
): Int {
if (from == to) return 0
return synchronized(lock) { migrateLocked(from = from, to = to) }
}
/** Resolves every parked alias and in-flight import, not only the visible composer. */
fun migrateMatching(
to: ChatComposerOwner,
mainSessionKey: String,
): ChatComposerAttachmentMigration =
synchronized(lock) {
val sources =
(_attachments.value.keys + importOwners.values)
.filterTo(linkedSetOf()) { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) }
val omitted = sources.sumOf { source -> migrateLocked(from = source, to = to) }
ChatComposerAttachmentMigration(sources = sources, omittedCount = omitted)
}
fun get(owner: ChatComposerOwner): List<PendingAttachment> = synchronized(lock) { _attachments.value[owner].orEmpty() }
private fun addLocked(
owner: ChatComposerOwner,
candidates: List<PendingAttachment>,
): Int {
val current = _attachments.value[owner].orEmpty()
val admission = admitWithAggregateLimit(owner = owner, current = current, candidates = candidates)
replaceLocked(owner, current + admission.accepted)
return admission.omittedCount
}
private fun migrateLocked(
from: ChatComposerOwner,
to: ChatComposerOwner,
): Int {
for ((id, owner) in importOwners.toMap()) {
if (owner == from) importOwners[id] = to
}
val source = _attachments.value[from].orEmpty()
if (source.isEmpty()) return 0
val destination = _attachments.value[to].orEmpty()
val admission = admitChatAttachments(currentAttachments = destination, candidates = source)
var next = _attachments.value - from
val merged = destination + admission.accepted
next = if (merged.isEmpty()) next - to else next + (to to merged)
_attachments.value = next
return admission.omittedCount
}
private fun admitWithAggregateLimit(
owner: ChatComposerOwner,
current: List<PendingAttachment>,
candidates: List<PendingAttachment>,
): ChatAttachmentAdmission {
val otherAttachments =
_attachments.value
.asSequence()
.filter { it.key != owner }
.flatMap { it.value.asSequence() }
var otherCount = 0
var otherBase64Chars = 0L
var otherDecodedBytes = 0L
for (attachment in otherAttachments) {
otherCount += 1
otherBase64Chars += attachment.base64.length.toLong()
otherDecodedBytes += decodedBase64ByteCount(attachment.base64)
}
return admitChatAttachments(
currentAttachments = current,
candidates = candidates,
maxAttachmentCount = minOf(CHAT_COMPOSER_MAX_ATTACHMENTS, (maxTotalAttachmentCount - otherCount).coerceAtLeast(0)),
maxBase64Chars = minOf(CHAT_COMPOSER_MAX_BASE64_CHARS, (maxTotalBase64Chars - otherBase64Chars).coerceAtLeast(0)),
maxDecodedBytes = minOf(CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES, (maxTotalDecodedBytes - otherDecodedBytes).coerceAtLeast(0)),
)
}
private fun replaceLocked(
owner: ChatComposerOwner,
next: List<PendingAttachment>,
) {
_attachments.value = if (next.isEmpty()) _attachments.value - owner else _attachments.value + (owner to next)
}
}
internal fun PendingAttachment.toOutgoingAttachment(): OutgoingAttachment =
OutgoingAttachment(
type = attachmentTypeForMimeType(mimeType),
@@ -2,6 +2,7 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.chat.AndroidVoiceNoteRecordingEngine
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.VoiceNoteRecorderController
import ai.openclaw.app.chat.VoiceNoteRecorderState
@@ -30,6 +31,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -49,12 +51,15 @@ import kotlinx.coroutines.withContext
@Composable
internal fun rememberVoiceNoteRecorderController(
viewModel: MainViewModel,
onFinished: (PendingAttachment) -> Unit,
ownerKey: ChatComposerOwner,
mainSessionKey: String,
onFinished: (String, PendingAttachment) -> Unit,
): VoiceNoteRecorderController {
val context = LocalContext.current.applicationContext
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val currentOnFinished by rememberUpdatedState(onFinished)
val ownerTracker = remember { VoiceNoteRecorderOwnerTracker(ownerKey) }
lateinit var controller: VoiceNoteRecorderController
controller =
remember(context, viewModel, scope) {
@@ -69,9 +74,10 @@ internal fun rememberVoiceNoteRecorderController(
scope.launch(Dispatchers.IO) {
val attachment = runCatching { stageVoiceNoteAttachment(recording) }
withContext(Dispatchers.Main) {
if (!controller.canCommitPreparation(recording.id)) return@withContext
attachment.fold(
onSuccess = {
currentOnFinished(it)
currentOnFinished(recording.id, it)
controller.completePreparation()
},
onFailure = { controller.reportFailure("Could not prepare voice note.") },
@@ -81,6 +87,9 @@ internal fun rememberVoiceNoteRecorderController(
},
)
}
LaunchedEffect(controller, ownerKey, mainSessionKey) {
if (!ownerTracker.moveTo(ownerKey, mainSessionKey)) controller.cancel()
}
DisposableEffect(controller, lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
@@ -95,6 +104,22 @@ internal fun rememberVoiceNoteRecorderController(
return controller
}
internal class VoiceNoteRecorderOwnerTracker(
initialOwner: ChatComposerOwner,
) {
private var owner = initialOwner
/** Returns false only when the next owner represents a genuinely different chat. */
fun moveTo(
next: ChatComposerOwner,
mainSessionKey: String,
): Boolean {
val retain = owner == next || shouldMigrateComposerDraft(owner, next, mainSessionKey)
owner = next
return retain
}
}
@Composable
internal fun VoiceNotePreparing(modifier: Modifier = Modifier) {
Surface(
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الإشعارات"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يتطلب التطبيق والرفض والحجر الصحي نطاق operator.admin. أعد الاتصال باستخدام مصادقة gateway المشتركة أو وافق على ترقية نطاق جهاز operator.admin لتمكين إجراءات دورة الحياة."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لا توجد جلسات مطابقة بعد."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تفاصيل سجل Gateway قابلة للقراءة."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اطلب من OpenClaw استخدام إمكانات Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"استخدم خيارات الاسترداد أدناه لإعادة الاتصال."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تعذر تحميل القنوات."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خلال %1$s يوم"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الأخطاء المتتالية"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Benachrichtigungen"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Anwenden, Ablehnen und Quarantäne erfordern den Bereich operator.admin. Stellen Sie die Verbindung mit gemeinsamer Gateway-Authentifizierung wieder her oder genehmigen Sie ein Upgrade des Geräte-Bereichs operator.admin, um Lifecycle-Aktionen zu aktivieren."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Noch keine passenden Sitzungen."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lesbare Gateway-Logdetails."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw bitten, Android-Funktionen zu verwenden."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nutze die Wiederherstellungsoptionen unten, um die Verbindung wiederherzustellen."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kanäle konnten nicht geladen werden."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In %1$s Tagen"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aufeinanderfolgende Fehler"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notificaciones"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aplicar, rechazar y poner en cuarentena requieren el ámbito operator.admin. Vuelve a conectar con la autenticación compartida del gateway o aprueba una actualización del ámbito operator.admin del dispositivo para habilitar las acciones de ciclo de vida."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aún no hay sesiones coincidentes."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detalle legible del registro del Gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pide a OpenClaw que use las funciones de Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Usa las opciones de recuperación que aparecen a continuación para volver a conectarte."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No se pudieron cargar los canales."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En %1$s d"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Errores consecutivos"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اعلان‌ها"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اعمال، رد و قرنطینه به دامنه operator.admin نیاز دارند. با احراز هویت مشترک gateway دوباره متصل شوید یا ارتقای دامنه operator.admin دستگاه را تأیید کنید تا اقدامات چرخه عمر فعال شوند."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"هنوز جلسهٔ مطابقی وجود ندارد."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جزئیات خوانای گزارش Gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"از OpenClaw بخواهید از قابلیت‌های Android استفاده کند."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"برای اتصال مجدد، از گزینه‌های بازیابی زیر استفاده کنید."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بارگیری کانال‌ها ممکن نشد."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s روز دیگر"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خطاهای متوالی"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notifications"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Appliquer, rejeter et mettre en quarantaine nécessitent la portée operator.admin. Reconnectez-vous avec l\'authentification partagée du gateway ou approuvez une mise à niveau de la portée operator.admin de l\'appareil pour activer les actions de cycle de vie."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aucune session correspondante pour le moment."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Détail lisible du journal du Gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Demandez à OpenClaw dutiliser les fonctionnalités dAndroid."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Utilisez les options de récupération ci-dessous pour vous reconnecter."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Impossible de charger les canaux."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dans %1$s j"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erreurs consécutives"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सूचनाएँ"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apply, reject और quarantine के लिए operator.admin स्कोप आवश्यक है। lifecycle क्रियाएं सक्षम करने के लिए साझा gateway auth से पुनः कनेक्ट करें या operator.admin डिवाइस स्कोप अपग्रेड को स्वीकृत करें।"</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"अभी तक कोई मेल खाता सेशन नहीं है।"</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"पढ़ने योग्य gateway लॉग विवरण।"</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw से Android क्षमताओं का उपयोग करने के लिए कहें।"</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फिर से कनेक्ट करने के लिए नीचे दिए गए पुनर्प्राप्ति विकल्पों का उपयोग करें।"</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"चैनल लोड नहीं किए जा सके।"</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$sदिन में"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"लगातार त्रुटियाँ"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notifikasi"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apply, reject, dan quarantine memerlukan scope operator.admin. Sambungkan ulang dengan autentikasi gateway bersama atau setujui peningkatan scope perangkat operator.admin untuk mengaktifkan tindakan siklus hidup."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Belum ada sesi yang cocok."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detail log gateway yang dapat dibaca."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Minta OpenClaw menggunakan kemampuan Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gunakan opsi pemulihan di bawah untuk terhubung kembali."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak dapat memuat saluran."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dalam %1$s hari"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kesalahan Berturut-turut"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notifiche"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Le azioni di applicazione, rifiuto e quarantena richiedono lo scope operator.admin. Riconnettiti con l\'autenticazione gateway condivisa o approva un aggiornamento dello scope del dispositivo operator.admin per abilitare le azioni del ciclo di vita."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ancora nessuna sessione corrispondente."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dettaglio leggibile del log del gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chiedi a OpenClaw di usare le funzionalità di Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Usa le opzioni di ripristino qui sotto per riconnetterti."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Impossibile caricare i canali."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tra %1$sg"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Errori consecutivi"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"通知"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"適用、拒否、隔離には operator.admin スコープが必要です。共有 gateway 認証で再接続するか、operator.admin デバイススコープのアップグレードを承認して、ライフサイクル操作を有効にしてください。"</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"一致するセッションはまだありません。"</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"読み取り可能な Gateway ログ詳細。"</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Androidの機能を使用するようOpenClawに依頼します。"</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"再接続するには、以下の復旧オプションを使用してください。"</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"チャンネルを読み込めませんでした。"</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s日後"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"連続エラー数"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"알림"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"적용, 거부, 격리에는 operator.admin 범위가 필요합니다. 공유 Gateway 인증으로 다시 연결하거나 operator.admin 기기 범위 업그레이드를 승인하여 수명 주기 작업을 활성화하세요."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"일치하는 세션이 아직 없습니다."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"읽기 쉬운 Gateway 로그 세부 정보."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw에 Android 기능 사용을 요청하세요."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"아래 복구 옵션을 사용하여 다시 연결하세요."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"채널을 불러올 수 없습니다."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s일 후"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"연속 오류"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Meldingen"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Toepassen, afwijzen en in quarantaine plaatsen vereisen de operator.admin-scope. Maak opnieuw verbinding met gedeelde gateway-authenticatie of keur een operator.admin-apparaatscopeupgrade goed om levenscyclusacties in te schakelen."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nog geen overeenkomende sessies."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leesbare Gateway-logdetails."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vraag OpenClaw om Android-mogelijkheden te gebruiken."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gebruik de herstelopties hieronder om opnieuw verbinding te maken."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kan kanalen niet laden."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Over %1$sd"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Opeenvolgende fouten"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Powiadomienia"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zastosowanie, odrzucenie i poddanie kwarantannie wymagają zakresu operator.admin. Połącz ponownie ze wspólnym uwierzytelnianiem gatewaya lub zatwierdź uaktualnienie zakresu urządzenia operator.admin, aby włączyć akcje cyklu życia."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nie ma jeszcze pasujących sesji."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Czytelne szczegóły dziennika Gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Poproś OpenClaw o użycie funkcji Androida."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Użyj poniższych opcji odzyskiwania, aby ponownie nawiązać połączenie."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nie udało się wczytać kanałów."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Za %1$s d"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kolejne błędy"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notificações"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aplicar, rejeitar e colocar em quarentena requerem o escopo operator.admin. Reconecte com a autenticação compartilhada do gateway ou aprove uma atualização de escopo de dispositivo operator.admin para habilitar as ações de ciclo de vida."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ainda não há sessões correspondentes."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detalhes legíveis do log do gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Peça ao OpenClaw para usar os recursos do Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use as opções de recuperação abaixo para se reconectar."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não foi possível carregar os canais."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Em %1$sd"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erros consecutivos"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Уведомления"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Действия «Применить», «Отклонить» и «Карантин» требуют области operator.admin. Переподключитесь с общей авторизацией gateway или подтвердите повышение области устройства до operator.admin, чтобы включить действия жизненного цикла."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Подходящих сеансов пока нет."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Подробные данные журнала Gateway в удобочитаемом виде."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Попросите OpenClaw использовать возможности Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Используйте варианты восстановления ниже, чтобы подключиться повторно."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не удалось загрузить каналы."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Через %1$s д."</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Последовательные ошибки"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aviseringar"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tillämpa, avvisa och sätt i karantän kräver scope operator.admin. Återanslut med delad gateway-autentisering eller godkänn en scope-uppgradering till operator.admin för enheten för att aktivera livscykelåtgärder."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inga matchande sessioner ännu."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läsbar detaljerad Gateway-logg."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Be OpenClaw att använda Android-funktioner."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Använd återställningsalternativen nedan för att återansluta."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Det gick inte att läsa in kanaler."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Om %1$s d"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fel i följd"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การแจ้งเตือน"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การใช้งาน การปฏิเสธ และการกักกันต้องใช้ขอบเขต operator.admin เชื่อมต่อใหม่ด้วยการยืนยันตัวตน gateway ที่ใช้ร่วมกัน หรืออนุมัติการอัปเกรดขอบเขตอุปกรณ์ operator.admin เพื่อเปิดใช้งานการดำเนินการวงจรชีวิต"</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ยังไม่มีเซสชันที่ตรงกัน"</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รายละเอียดบันทึก Gateway ที่อ่านได้"</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ขอให้ OpenClaw ใช้ความสามารถของ Android"</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ใช้ตัวเลือกการกู้คืนด้านล่างเพื่อเชื่อมต่ออีกครั้ง"</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่สามารถโหลดช่องได้"</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ในอีก %1$s วัน"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ข้อผิดพลาดต่อเนื่อง"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bildirimler"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uygulama, reddetme ve karantina işlemleri operator.admin kapsamı gerektirir. Yaşam döngüsü işlemlerini etkinleştirmek için paylaşılan gateway kimlik doğrulamasıyla yeniden bağlanın veya bir operator.admin cihaz kapsamı yükseltmesini onaylayın."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Henüz eşleşen oturum yok."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Okunabilir gateway günlük ayrıntısı."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw\'dan Android özelliklerini kullanmasını isteyin."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yeniden bağlanmak için aşağıdaki kurtarma seçeneklerini kullanın."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kanallar yüklenemedi."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s gün içinde"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ardışık Hatalar"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сповіщення"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Для застосування, відхилення та карантину потрібна область operator.admin. Повторно підключіться зі спільною автентифікацією gateway або схваліть підвищення області operator.admin для пристрою, щоб увімкнути дії життєвого циклу."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відповідних сеансів поки немає."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Зручні для читання відомості журналу Gateway."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Попросіть OpenClaw скористатися можливостями Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Скористайтеся наведеними нижче варіантами відновлення, щоб повторно підключитися."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не вдалося завантажити канали."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Через %1$s дн."</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Послідовні помилки"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thông báo"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Áp dụng, từ chối và cách ly yêu cầu phạm vi operator.admin. Kết nối lại bằng xác thực gateway dùng chung hoặc phê duyệt nâng cấp phạm vi thiết bị operator.admin để bật các hành động vòng đời."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chưa có phiên phù hợp."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chi tiết nhật ký gateway dễ đọc."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yêu cầu OpenClaw sử dụng các tính năng của Android."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sử dụng các tùy chọn khôi phục bên dưới để kết nối lại."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không thể tải các kênh."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sau %1$s ngày"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lỗi liên tiếp"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"通知"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"应用、拒绝和隔离操作需要 operator.admin 权限范围。请使用共享 gateway 认证重新连接,或批准 operator.admin 设备权限范围升级以启用生命周期操作。"</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"尚无匹配的会话。"</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"可读的 Gateway 日志详情。"</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"让 OpenClaw 使用 Android 功能。"</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"使用下方的恢复选项重新连接。"</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"无法加载频道。"</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s 天后"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"连续错误次数"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"通知"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"套用、拒絕與隔離需要 operator.admin 範圍。請以共用 gateway 驗證重新連線,或核准 operator.admin 裝置範圍升級以啟用生命週期動作。"</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"尚無相符的工作階段。"</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"可讀取的 Gateway 記錄詳細資料。"</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請 OpenClaw 使用 Android 功能。"</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請使用下方的復原選項重新連線。"</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"無法載入頻道。"</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s 天後"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"連續錯誤次數"</string>
@@ -710,6 +710,7 @@
<string name="native_788011833a5a0f22" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Notifications"</string>
<string name="native_78af93f13a7e5044" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions."</string>
<string name="native_78f25f393aa9bcbf" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker upload"</string>
<string name="native_790d78931c21b895" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messages to recover"</string>
<string name="native_792175fb265c32bc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"openclaw devices approve %1$s"</string>
<string name="native_7981cbd5763dfcbb" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No matching sessions yet."</string>
<string name="native_79a68a0d42388125" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Readable gateway log detail."</string>
@@ -1301,6 +1302,7 @@
<string name="native_e2d1ec2328a8146f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask OpenClaw to use Android capabilities."</string>
<string name="native_e31ab643c44f7a0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"member"</string>
<string name="native_e330f9d64f5ad4dd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use the recovery options below to reconnect."</string>
<string name="native_e3399e6ed762c252" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows."</string>
<string name="native_e35e24d39a82aec2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Could not load channels."</string>
<string name="native_e3cda2d4381e42cd" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In %1$sd"</string>
<string name="native_e3d8e1685f141cba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Consecutive Errors"</string>
@@ -1,9 +1,13 @@
package ai.openclaw.app
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.gateway.GatewayRegistryEntry
import ai.openclaw.app.gateway.GatewayRegistryEntryKind
import ai.openclaw.app.ui.chat.PendingAttachment
import android.content.Context
import android.content.Intent
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -121,6 +125,244 @@ class MainViewModelTest {
assertEquals(listOf(gateway), prefs.gatewayRegistry.entries.value)
}
@Test
fun assistantLaunchDraftCapturesItsProvisionalComposerOwner() {
val (viewModel, _) = createViewModel()
viewModel.handleAssistantLaunch(
AssistantLaunchRequest(
source = "app_action",
prompt = "captured prompt",
autoSend = false,
),
)
val draft = requireNotNull(viewModel.chatDraft.value)
val captured = requireNotNull(draft.owner)
assertEquals("captured prompt", draft.text)
assertNull(
claimChatDraftForOwner(
draft = draft,
owner = captured.copy(gatewayStableId = "another-gateway", agentId = "another-agent"),
mainSessionKey = "agent:another-agent:main",
),
)
}
@Test
fun assistantAutoSendCapturesAndMigratesItsProvisionalComposerOwner() {
val (viewModel, _) = createViewModel()
viewModel.handleAssistantLaunch(
AssistantLaunchRequest(
source = "app_action",
prompt = "send to the captured chat",
autoSend = true,
),
)
val pending = requireNotNull(viewModel.pendingAssistantAutoSend.value)
val resolvedOwner =
pending.owner.copy(
agentId = "work",
sessionKey = "agent:work:device",
routingVerified = true,
)
viewModel.resolveChatComposerOwnerAliases(to = resolvedOwner, mainSessionKey = resolvedOwner.sessionKey)
assertEquals("send to the captured chat", viewModel.pendingAssistantAutoSend.value?.prompt)
assertEquals(resolvedOwner, viewModel.pendingAssistantAutoSend.value?.owner)
}
@Test
fun mediaAuthorizationMigratesWithItsProvisionalComposerOwner() {
val (viewModel, _) = createViewModel()
val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false)
val resolved = ChatComposerOwner("gateway", "work", "agent:work:device")
val authorizationId = requireNotNull(viewModel.beginChatComposerMediaAcquisition(provisional))
viewModel.resolveChatComposerOwnerAliases(to = resolved, mainSessionKey = resolved.sessionKey)
assertEquals(
0,
viewModel.addChatComposerAttachments(
owner = resolved,
mediaAuthorizationId = authorizationId,
attachments = listOf(PendingAttachment("migrated", "photo.jpg", "image/jpeg", "YQ==")),
),
)
assertEquals(1, viewModel.chatComposerAttachments.value[resolved]?.size)
}
@Test
fun completedAssistantAutoSendClearsItsMigratedOperationButNotAReplacement() {
val original =
PendingAssistantAutoSend(
prompt = "send once",
owner = ChatComposerOwner("gateway", "main", "main"),
)
val migrated = original.copy(owner = original.owner.copy(sessionKey = "agent:main:device"))
val replacement = PendingAssistantAutoSend(prompt = original.prompt, owner = migrated.owner)
assertNull(clearCompletedAssistantAutoSend(migrated, original.id))
assertEquals(replacement, clearCompletedAssistantAutoSend(replacement, original.id))
}
@Test
fun refusedAssistantPromptBecomesEditableWithoutOverwritingNewerText() {
assertEquals("send once", retainRefusedAssistantPrompt("send once", ""))
assertEquals("send once\n\nnewer edit", retainRefusedAssistantPrompt("send once", "newer edit"))
assertEquals("send once", retainRefusedAssistantPrompt("send once", "send once"))
}
@Test
fun assistantAutoSendSharesTheManualComposerAdmissionGate() {
val owner =
ai.openclaw.app.chat
.ChatComposerOwner("gateway", "main", "agent:main:device")
assertTrue(chatComposerOwnerHasActiveSend(owner, setOf(owner), emptyMap()))
assertTrue(
chatComposerOwnerHasActiveSend(
owner,
emptySet(),
mapOf(
owner to
ChatComposerSendAdmission(
id = 1,
owner = owner,
),
),
),
)
assertFalse(chatComposerOwnerHasActiveSend(owner, emptySet(), emptyMap()))
}
@Test
fun gatewayAuthResetCleanupPurgesOnlyThatGatewaysComposerState() =
runBlocking {
val (viewModel, _) = createViewModel()
val removed =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-a", "main", "main")
val retained =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-b", "main", "main")
viewModel.chatComposerTextDrafts[removed] = "private a"
viewModel.chatComposerTextDrafts[retained] = "private b"
val removedAttachment = PendingAttachment("a", "a.txt", "text/plain", "YQ==")
val retainedAttachment = PendingAttachment("b", "b.txt", "text/plain", "Yg==")
viewModel.addChatComposerAttachments(removed, listOf(removedAttachment))
viewModel.addChatComposerAttachments(retained, listOf(retainedAttachment))
viewModel.clearChatComposerGateway("gateway-a")
assertEquals("", viewModel.chatComposerTextDrafts[removed])
assertEquals("private b", viewModel.chatComposerTextDrafts[retained])
assertEquals(null, viewModel.chatComposerAttachments.value[removed])
assertEquals(listOf(retainedAttachment), viewModel.chatComposerAttachments.value[retained])
}
@Test
fun gatewayAuthResetRejectsMediaCompletionsCapturedByRetiredCredentials() =
runBlocking {
val (viewModel, _) = createViewModel()
val owner = ChatComposerOwner("gateway-a", "main", "main")
val authorizationId = requireNotNull(viewModel.beginChatComposerMediaAcquisition(owner))
var imageLoaderCalled = false
viewModel.clearChatComposerGateway("gateway-a")
assertFalse(viewModel.isChatComposerMediaAcquisitionActive(authorizationId))
assertNull(
viewModel.addChatComposerAttachments(
owner = owner,
mediaAuthorizationId = authorizationId,
attachments = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")),
),
)
viewModel.importChatComposerAttachments(owner, authorizationId, mainSessionKey = "main", expectedCount = 1) {
imageLoaderCalled = true
listOf(PendingAttachment("late-image", "late.jpg", "image/jpeg", "YQ=="))
}
assertFalse(imageLoaderCalled)
assertNull(viewModel.chatComposerAttachments.value[owner])
}
@Test
fun deletedSessionCleanupPurgesItsMainAliasesWithoutTouchingSiblingOwners() =
runBlocking {
val (viewModel, _) = createViewModel()
val alias =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-a", "main", "main")
val canonical =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-a", "main", "agent:main:device")
val provisional =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-a", "placeholder", "main", routingVerified = false)
val sibling =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-a", "main", "agent:main:other")
val otherAgent =
ai.openclaw.app.chat
.ChatComposerOwner("gateway-a", "work", "agent:main:device")
val mediaAuthorizationId = requireNotNull(viewModel.beginChatComposerMediaAcquisition(canonical))
listOf(alias, canonical, provisional, sibling, otherAgent).forEach { owner ->
viewModel.chatComposerTextDrafts[owner] = owner.toString()
viewModel.addChatComposerAttachments(
owner,
listOf(PendingAttachment(owner.toString(), "draft.txt", "text/plain", "YQ==")),
)
}
viewModel.clearChatComposerSession(
gatewayStableId = "gateway-a",
agentId = "main",
sessionKey = "main",
mainSessionKey = "agent:main:device",
)
assertEquals("", viewModel.chatComposerTextDrafts[alias])
assertEquals("", viewModel.chatComposerTextDrafts[canonical])
assertEquals("", viewModel.chatComposerTextDrafts[provisional])
assertEquals(sibling.toString(), viewModel.chatComposerTextDrafts[sibling])
assertEquals(otherAgent.toString(), viewModel.chatComposerTextDrafts[otherAgent])
assertEquals(null, viewModel.chatComposerAttachments.value[alias])
assertEquals(null, viewModel.chatComposerAttachments.value[canonical])
assertEquals(null, viewModel.chatComposerAttachments.value[provisional])
assertEquals(1, viewModel.chatComposerAttachments.value[sibling]?.size)
assertEquals(1, viewModel.chatComposerAttachments.value[otherAgent]?.size)
assertFalse(viewModel.isChatComposerMediaAcquisitionActive(mediaAuthorizationId))
assertNull(
viewModel.addChatComposerAttachments(
owner = canonical,
mediaAuthorizationId = mediaAuthorizationId,
attachments = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")),
),
)
}
@Test
fun replyDraftRejectsACallbackCapturedForAnotherChat() {
val (viewModel, _) = createViewModel()
viewModel.handleAssistantLaunch(
AssistantLaunchRequest(
source = "app_action",
prompt = "initial",
autoSend = false,
),
)
val owner = requireNotNull(viewModel.chatDraft.value?.owner)
viewModel.setChatReplyDraft("quoted", owner)
assertEquals("quoted", viewModel.chatDraft.value?.text)
viewModel.setChatReplyDraft("stale", owner.copy(sessionKey = "agent:main:another"))
assertEquals("quoted", viewModel.chatDraft.value?.text)
}
private fun assertNodeServiceStopRequested() {
val app = RuntimeEnvironment.getApplication()
val intent: Intent? = shadowOf(app).nextStartedService
@@ -148,7 +390,7 @@ class MainViewModelTest {
Context.MODE_PRIVATE,
),
)
return MainViewModel(app, prefs) to prefs
return MainViewModel(app, prefs, SavedStateHandle()) to prefs
}
private fun draft(name: String): CronEditorDraftState {
@@ -99,57 +99,80 @@ class ShareLaunchTest {
@Test
fun rapidSharesKeepStableHeadUntilMatchingAcknowledgement() {
val queue = ChatShareDraftQueue(capacity = 2)
val owner = composerOwner("main", "agent:main:device")
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
val second = ChatShareDraft(id = 2, text = "second", imageUris = emptyList(), droppedImageCount = 0)
assertTrue(queue.enqueue(first))
assertTrue(queue.enqueue(second))
assertTrue(queue.enqueue(first, owner))
assertTrue(queue.enqueue(second, owner))
assertEquals(first, queue.head.value)
assertFalse(queue.acknowledgeHead(second.id))
assertFalse(queue.acknowledgeHead(second.id, owner))
assertEquals(first, queue.head.value)
assertTrue(queue.acknowledgeHead(first.id))
runBlocking { assertTrue(queue.withHeadLease(first.id, owner) {}) }
assertTrue(queue.acknowledgeHead(first.id, owner))
assertEquals(second, queue.head.value)
assertTrue(queue.acknowledgeHead(second.id))
runBlocking { assertTrue(queue.withHeadLease(second.id, owner) {}) }
assertTrue(queue.acknowledgeHead(second.id, owner))
assertNull(queue.head.value)
}
@Test
fun pendingShareQueueIsBoundedWithoutReplacingItsHead() {
val queue = ChatShareDraftQueue(capacity = 1)
val owner = composerOwner("main", "agent:main:device")
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
val overflow = ChatShareDraft(id = 2, text = "overflow", imageUris = emptyList(), droppedImageCount = 0)
assertTrue(queue.enqueue(first))
assertFalse(queue.enqueue(overflow))
assertTrue(queue.enqueue(first, owner))
assertFalse(queue.enqueue(overflow, owner))
assertEquals(1, queue.size())
assertEquals(first, queue.head.value)
}
@Test
fun anotherOwnersShareCanAdvanceWithoutRetargetingTheGlobalHead() =
runBlocking {
val queue = ChatShareDraftQueue(capacity = 2)
val ownerA = composerOwner("agent-a", "session-a")
val ownerB = composerOwner("agent-b", "session-b")
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
val second = ChatShareDraft(id = 2, text = "second", imageUris = emptyList(), droppedImageCount = 0)
queue.enqueue(first, ownerA)
queue.enqueue(second, ownerB)
assertEquals(first, queue.head.value)
assertTrue(queue.withHeadLease(second.id, ownerB) {})
assertTrue(queue.acknowledgeHead(second.id, ownerB))
assertEquals(first, queue.head.value)
assertTrue(queue.withHeadLease(first.id, ownerA) {})
}
@Test
fun overlappingActivityLoadersCannotCommitTheSameHead() =
runBlocking {
val queue = ChatShareDraftQueue(capacity = 2)
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
val next = ChatShareDraft(id = 2, text = "second", imageUris = emptyList(), droppedImageCount = 0)
queue.enqueue(first)
queue.enqueue(next)
val owner = composerOwner("main", "agent:main:device")
queue.enqueue(first, owner)
queue.enqueue(next, owner)
val entered = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
val firstLoader =
async {
queue.withHeadLease(first.id) {
queue.withHeadLease(first.id, owner) {
entered.complete(Unit)
release.await()
assertTrue(queue.acknowledgeHead(first.id))
assertTrue(queue.acknowledgeHead(first.id, owner))
}
}
entered.await()
var staleLoaderRan = false
val staleLoader =
async {
queue.withHeadLease(first.id) {
queue.withHeadLease(first.id, owner) {
staleLoaderRan = true
}
}
@@ -160,4 +183,66 @@ class ShareLaunchTest {
assertFalse(staleLoaderRan)
assertEquals(next, queue.head.value)
}
@Test
fun claimedShareCannotRetargetAcrossComposerNavigation() =
runBlocking {
val queue = ChatShareDraftQueue(capacity = 1)
val share = ChatShareDraft(id = 1, text = "private", imageUris = emptyList(), droppedImageCount = 0)
val ownerA = composerOwner("agent-a", "session-a")
val ownerB = composerOwner("agent-b", "session-b")
val resolvedA = ownerA.copy(sessionKey = "agent:agent-a:device")
queue.enqueue(share, ownerA)
assertTrue(queue.withHeadLease(share.id, ownerA) {})
assertFalse(queue.withHeadLease(share.id, ownerB) {})
assertFalse(queue.acknowledgeHead(share.id, ownerB))
queue.migrateOwner(ownerA, resolvedA)
assertTrue(queue.withHeadLease(share.id, resolvedA) {})
assertTrue(queue.acknowledgeHead(share.id, resolvedA))
}
@Test
fun shareOwnerIsCapturedBeforeAnyLoaderRuns() =
runBlocking {
val queue = ChatShareDraftQueue(capacity = 1)
val share = ChatShareDraft(id = 1, text = "private", imageUris = emptyList(), droppedImageCount = 0)
val ownerA = composerOwner("agent-a", "session-a")
val ownerB = composerOwner("agent-b", "session-b")
assertTrue(queue.enqueue(share, ownerA))
assertEquals(ownerA, queue.ownerOf(share.id))
assertFalse(queue.withHeadLease(share.id, ownerB) {})
assertTrue(queue.withHeadLease(share.id, ownerA) {})
}
@Test
fun removingGatewaySharesKeepsOtherGatewayOwners() =
runBlocking {
val queue = ChatShareDraftQueue(capacity = 2)
val ownerA = composerOwner("agent-a", "session-a", gatewayStableId = "gateway-a")
val ownerB = composerOwner("agent-b", "session-b", gatewayStableId = "gateway-b")
val first = ChatShareDraft(id = 1, text = "private a", imageUris = emptyList(), droppedImageCount = 0)
val second = ChatShareDraft(id = 2, text = "private b", imageUris = emptyList(), droppedImageCount = 0)
queue.enqueue(first, ownerA)
queue.enqueue(second, ownerB)
queue.removeOwners { it.gatewayStableId == "gateway-a" }
assertEquals(listOf(second), queue.queued.value)
assertNull(queue.ownerOf(first.id))
assertEquals(ownerB, queue.ownerOf(second.id))
}
private fun composerOwner(
agentId: String,
sessionKey: String,
gatewayStableId: String = "gateway",
): ai.openclaw.app.chat.ChatComposerOwner =
ai.openclaw.app.chat.ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = agentId,
sessionKey = sessionKey,
)
}
@@ -25,14 +25,15 @@ class ChatCacheDatabaseMigrationTest {
val database = ChatCacheDatabase.open(context, databaseName)
try {
// Opening through Room executes the production migration chain and validates the
// complete v4 schema, including columns, nullability, primary keys, and indices.
assertEquals(4, database.openHelper.writableDatabase.version)
// complete v8 schema, including columns, nullability, primary keys, and indices.
assertEquals(8, database.openHelper.writableDatabase.version)
val outbox = RoomChatCommandOutbox(database)
val rows = outbox.load("gateway-test").associateBy { it.id }
val pristine = rows.getValue("pristine")
assertEquals(ChatOutboxStatus.Queued, pristine.status)
assertNull(pristine.lastError)
assertEquals(ChatOutboxStatus.Failed, pristine.status)
assertEquals(OUTBOX_OWNER_CHANGED_ERROR, pristine.lastError)
assertNull(pristine.ownerAgentId)
assertNull(pristine.gatedEpoch)
assertTrue(pristine.attachments.isEmpty())
@@ -44,19 +45,24 @@ class ChatCacheDatabaseMigrationTest {
val alreadyFailed = rows.getValue("already-failed")
assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status)
assertEquals("original failure", alreadyFailed.lastError)
val accepted = rows.getValue("accepted")
assertEquals(ChatOutboxStatus.Failed, accepted.status)
assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, accepted.lastError)
val explicitOwner = rows.getValue("explicit-owner")
assertEquals(ChatOutboxStatus.Queued, explicitOwner.status)
assertEquals("ops", explicitOwner.ownerAgentId)
outbox.deleteForSession("gateway-test", "agent:ops:side", "ops")
assertTrue(outbox.load("gateway-test").none { it.id == explicitOwner.id })
// Legacy queued command-shaped rows predate connection epochs; the sentinel keeps
// them from silently replaying on the next reconnect (they park for explicit retry).
val legacyCommand = rows.getValue("legacy-command")
assertEquals(ChatOutboxStatus.Queued, legacyCommand.status)
assertEquals(ChatOutboxStatus.Failed, legacyCommand.status)
assertEquals(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch)
assertEquals(
"Cached session",
database
.dao()
.sessions("gateway-test")
.single()
.displayName,
)
assertEquals(OUTBOX_OWNER_CHANGED_ERROR, legacyCommand.lastError)
// Legacy session and transcript rows have no trustworthy agent owner. Both disposable
// caches are rebuilt instead of exposing another agent's metadata or history.
assertTrue(database.dao().sessions("gateway-test", "main").isEmpty())
assertTrue(database.dao().messages("gateway-test", "main", "main").isEmpty())
} finally {
database.close()
context.deleteDatabase(databaseName)
@@ -84,6 +90,7 @@ class ChatCacheDatabaseMigrationTest {
text = "post-upgrade media",
thinkingLevel = "off",
nowMs = System.currentTimeMillis(),
ownerAgentId = "main",
attachments =
listOf(
OutboxAttachmentPayload(type = "image", mimeType = "image/jpeg", fileName = "a.jpg", durationMs = null, bytes = bytes),
@@ -131,6 +138,12 @@ class ChatCacheDatabaseMigrationTest {
"(gatewayId, sessionKey, displayName, updatedAtMs, rowOrder) VALUES (?, ?, ?, ?, ?)",
arrayOf<Any?>("gateway-test", "main", "Cached session", 10L, 0),
)
database.execSQL(
"INSERT INTO cached_messages " +
"(gatewayId, sessionKey, rowOrder, role, textPartsJson, timestampMs, idempotencyKey) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
arrayOf<Any?>("gateway-test", "main", 0, "assistant", "[\"legacy transcript\"]", 10L, null),
)
insertOutbox(database, id = "pristine", status = "queued", retryCount = 0, lastError = null, createdAtMs = now)
insertOutbox(
database,
@@ -165,6 +178,23 @@ class ChatCacheDatabaseMigrationTest {
createdAtMs = now + 4,
text = "/clear",
)
insertOutbox(
database,
id = "accepted",
status = "accepted",
retryCount = 0,
lastError = null,
createdAtMs = now + 5,
)
insertOutbox(
database,
id = "explicit-owner",
status = "queued",
retryCount = 0,
lastError = null,
createdAtMs = now + 6,
sessionKey = "agent:ops:side",
)
database.version = 2
}
}
@@ -177,12 +207,13 @@ class ChatCacheDatabaseMigrationTest {
lastError: String?,
createdAtMs: Long,
text: String = id,
sessionKey: String = "main",
) {
database.execSQL(
"INSERT INTO outbox_commands " +
"(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(id, "gateway-test", "main", text, "off", createdAtMs, status, retryCount, lastError),
arrayOf<Any?>(id, "gateway-test", sessionKey, text, "off", createdAtMs, status, retryCount, lastError),
)
}
}
@@ -296,22 +296,28 @@ class ChatControllerCommandControlsTest {
json = json,
requestGateway = { method, paramsJson ->
requests += method to paramsJson
if (method == "sessions.list") """{"sessions":[]}""" else "{}"
when (method) {
"sessions.list" -> """{"sessions":[]}"""
"sessions.delete" -> """{"deleted":true}"""
else -> "{}"
}
},
)
controller.patchSession(
key = "main",
ownerAgentId = "owner-a",
clearLabel = true,
clearCategory = true,
pinned = true,
archived = false,
unread = true,
)
controller.deleteSession("main")
controller.deleteSession("main", ownerAgentId = "main")
val patch = requests.first { it.first == "sessions.patch" }.second.orEmpty()
assertTrue(patch.contains("\"key\":\"main\""))
assertTrue(patch.contains("\"agentId\":\"owner-a\""))
assertTrue(patch.contains("\"label\":null"))
assertTrue(patch.contains("\"category\":null"))
assertTrue(patch.contains("\"pinned\":true"))
@@ -422,14 +428,20 @@ class ChatControllerCommandControlsTest {
val create = requests.first { it.first == "sessions.create" }.second.orEmpty()
assertTrue(create.contains("\"parentSessionKey\":\"main\""))
assertTrue(create.contains("\"fork\":true"))
// Unqualified parent keys leave agent resolution to the gateway.
assertEquals(false, create.contains("\"agentId\""))
// The active unqualified parent keeps the captured default-agent owner.
assertTrue(create.contains("\"agentId\":\"main\""))
// Agent-qualified parents keep the fork under the parent's agent.
controller.forkSession("agent:ops:dashboard:abc")
val scopedCreate = requests.last { it.first == "sessions.create" }.second.orEmpty()
assertTrue(scopedCreate.contains("\"parentSessionKey\":\"agent:ops:dashboard:abc\""))
assertTrue(scopedCreate.contains("\"agentId\":\"ops\""))
// Unqualified list rows carry their captured owner through a later default-agent change.
controller.forkSession("custom", ownerAgentId = "owner-a")
val capturedOwnerCreate = requests.last { it.first == "sessions.create" }.second.orEmpty()
assertTrue(capturedOwnerCreate.contains("\"parentSessionKey\":\"custom\""))
assertTrue(capturedOwnerCreate.contains("\"agentId\":\"owner-a\""))
assertTrue(requests.any { it.first == "sessions.list" })
assertEquals(
false,
@@ -507,7 +519,7 @@ class ChatControllerCommandControlsTest {
// Another client cleared the group and name; the gateway sends explicit nulls.
controller.handleGatewayEvent(
"sessions.changed",
"""{"sessionKey":"main","session":{"key":"main","label":null,"category":null}}""",
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","label":null,"category":null}}""",
)
advanceUntilIdle()
val merged = controller.sessions.value.single()
@@ -545,7 +557,7 @@ class ChatControllerCommandControlsTest {
failPatches = false
controller.handleGatewayEvent(
"sessions.changed",
"""{"sessionKey":"main","session":{"key":"main","unread":true}}""",
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true}}""",
)
advanceUntilIdle()
assertEquals(2, requests.count { it.first == "sessions.patch" })
@@ -564,6 +576,7 @@ class ChatControllerCommandControlsTest {
requests += method to paramsJson
when (method) {
"sessions.list" -> """{"sessions":[{"key":"agent:main:side"}]}"""
"sessions.delete" -> """{"deleted":true}"""
else -> "{}"
}
},
@@ -611,7 +624,7 @@ class ChatControllerCommandControlsTest {
// A run completes while the session stays open: the gateway flags it unread again.
controller.handleGatewayEvent(
"sessions.changed",
"""{"sessionKey":"main","session":{"key":"main","unread":true}}""",
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true}}""",
)
advanceUntilIdle()
assertEquals(1, requests.count { it.first == "sessions.patch" })
@@ -619,12 +632,12 @@ class ChatControllerCommandControlsTest {
// Server-confirmed read resets the episode; a stale duplicate must not re-patch.
controller.handleGatewayEvent(
"sessions.changed",
"""{"sessionKey":"main","session":{"key":"main","unread":false}}""",
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":false}}""",
)
advanceUntilIdle()
controller.handleGatewayEvent(
"sessions.changed",
"""{"sessionKey":"main","session":{"key":"main","unread":true}}""",
"""{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true}}""",
)
advanceUntilIdle()
assertEquals(2, requests.count { it.first == "sessions.patch" })
@@ -40,7 +40,7 @@ class ChatControllerModelSelectionTest {
assertEquals(listOf("anthropic/claude-opus-4"), recents)
assertEquals("anthropic/claude-opus-4", controller.selectedModelRef.value)
assertEquals(
"sessions.patch" to "{\"key\":\"main\",\"model\":\"anthropic/claude-opus-4\"}",
"sessions.patch" to "{\"key\":\"main\",\"agentId\":\"main\",\"model\":\"anthropic/claude-opus-4\"}",
requests.single(),
)
}
@@ -194,7 +194,7 @@ class ChatControllerModelSelectionTest {
assertTrue(controller.setSessionModelAwait("main", null))
assertEquals(emptyList<String>(), recents)
assertEquals("{\"key\":\"main\",\"model\":null}", requests.single())
assertEquals("{\"key\":\"main\",\"agentId\":\"main\",\"model\":null}", requests.single())
}
@Test
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,12 @@
package ai.openclaw.app.chat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -106,6 +109,23 @@ class ChatControllerSessionSearchTest {
assertTrue(controller.fetchSessionList(search = null, archived = true).isEmpty())
}
@Test
fun fetchSessionListDoesNotGuessMainWhileDefaultOwnerIsUnknown() =
runTest {
val gateway = ScriptedGateway(json)
val controller =
ChatController(
scope = this,
json = json,
requestGateway = gateway::request,
currentDefaultAgentId = { null },
)
assertTrue(controller.fetchSessionList(search = "trip", archived = false).isEmpty())
assertTrue(controller.fetchSessionList(search = null, archived = true).isEmpty())
assertTrue(gateway.calls.isEmpty())
}
@Test
fun fetchSessionListRethrowsCancellationInsteadOfFallingBack() =
runTest {
@@ -120,4 +140,38 @@ class ChatControllerSessionSearchTest {
// A superseded search must cancel, not repaint stale fallback rows.
}
}
@Test
fun fetchSessionListDropsAResponseAfterOwnerOrGatewayChanges() =
runTest {
var defaultAgentId = "agent-a"
var defaultAgentRevision = 1L
var cacheScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1)
val requestStarted = CompletableDeferred<Unit>()
val releaseResponse = CompletableDeferred<String>()
val gateway = ScriptedGateway(json)
gateway.respond("sessions.list") {
requestStarted.complete(Unit)
releaseResponse.await()
}
val controller =
ChatController(
scope = this,
json = json,
requestGateway = gateway::request,
cacheScope = { cacheScope },
currentDefaultAgentId = { defaultAgentId },
currentDefaultAgentRevision = { defaultAgentRevision },
)
val pending = async { controller.fetchSessionList(search = "trip", archived = false) }
runCurrent()
requestStarted.await()
defaultAgentId = "agent-b"
defaultAgentRevision += 1
cacheScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2)
releaseResponse.complete(sessionsListJson(sessionRowJson(key = "agent:agent-a:old", updatedAt = 10)))
assertTrue(pending.await().isEmpty())
}
}
@@ -3,8 +3,11 @@ package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewayRequestNotEnqueued
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
@@ -18,6 +21,172 @@ import org.junit.Test
class ChatControllerTerminalAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun composerOwnerMustMatchBeforeSendAdmission() =
runTest {
val requestedMethods = mutableListOf<String>()
var defaultAgentId: String? = "main"
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethods += method
"""{"runId":"run-started","status":"started"}"""
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
currentDefaultAgentId = { defaultAgentId },
)
controller.handleGatewayEvent("health", null)
val ambiguousOwner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
assertFalse(controller.canSendForOwner(ambiguousOwner))
assertFalse(
controller.sendMessageForOwnerAwaitAcceptance(
message = "unbound main alias",
thinkingLevel = "off",
attachments = emptyList(),
expectedOwner = ambiguousOwner,
),
)
controller.prepareMainSessionKey("agent:main:node-test")
controller.handleGatewayEvent("health", null)
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:node-test")
assertTrue(controller.canSendForOwner(owner))
assertFalse(controller.canSendForOwner(owner.copy(gatewayStableId = "gateway-b")))
assertFalse(
controller.sendMessageForOwnerAwaitAcceptance(
message = "wrong gateway",
thinkingLevel = "off",
attachments = emptyList(),
expectedOwner = owner.copy(gatewayStableId = "gateway-b"),
),
)
assertFalse(
controller.sendMessageForOwnerAwaitAcceptance(
message = "wrong session",
thinkingLevel = "off",
attachments = emptyList(),
expectedOwner = owner.copy(sessionKey = "agent:other:main", agentId = "other"),
),
)
assertTrue(
controller.sendMessageForOwnerAwaitAcceptance(
message = "correct owner",
thinkingLevel = "off",
attachments = emptyList(),
expectedOwner = owner,
),
)
assertEquals(1, requestedMethods.count { it == "chat.send" })
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun composerOwnerIsRecheckedAfterPendingSettingsComplete() =
runTest {
val settingsStarted = CompletableDeferred<Unit>()
val settingsGate = CompletableDeferred<Unit>()
var defaultAgentId: String? = "main"
var sendCount = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"sessions.patch" -> {
settingsStarted.complete(Unit)
settingsGate.await()
"{}"
}
"chat.send" -> {
sendCount += 1
"""{"runId":"run-started","status":"started"}"""
}
else -> "{}"
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
currentDefaultAgentId = { defaultAgentId },
)
controller.prepareMainSessionKey("agent:main:node-test")
controller.handleGatewayEvent("health", null)
controller.setThinkingLevel("high")
settingsStarted.await()
val accepted =
async {
controller.sendMessageForOwnerAwaitAcceptance(
message = "stale after settings",
thinkingLevel = "high",
attachments = emptyList(),
expectedOwner =
ChatComposerOwner(
gatewayStableId = "gateway-a",
agentId = "main",
sessionKey = "agent:main:node-test",
),
)
}
runCurrent()
controller.switchSession("agent:other:main")
settingsGate.complete(Unit)
assertFalse(accepted.await())
assertEquals(0, sendCount)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun unjournaledNotEnqueuedSendRemainsRejectedAfterOwnerChange() =
runTest {
val requestGate = CompletableDeferred<Unit>()
var defaultAgentId: String? = "main"
var defaultAgentRevision = 0L
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
if (method == "chat.send") {
requestGate.await()
throw GatewayRequestNotEnqueued("not enqueued")
}
"{}"
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
currentDefaultAgentId = { defaultAgentId },
currentDefaultAgentRevision = { defaultAgentRevision },
)
controller.prepareMainSessionKey("agent:main:node-test")
controller.handleGatewayEvent("health", null)
val accepted =
async {
controller.sendMessageForOwnerAwaitAcceptance(
message = "keep my draft",
thinkingLevel = "off",
attachments = emptyList(),
expectedOwner =
ChatComposerOwner(
gatewayStableId = "gateway-a",
agentId = "main",
sessionKey = "agent:main:node-test",
),
)
}
runCurrent()
controller.switchSession("agent:other:main")
requestGate.complete(Unit)
assertFalse(accepted.await())
assertEquals(0, controller.pendingRunCount.value)
assertTrue(controller.messages.value.none { message -> message.content.any { it.text == "keep my draft" } })
assertNull(controller.errorText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
@@ -31,6 +200,7 @@ class ChatControllerTerminalAckTest {
requestedMethod = method
"""{"runId":"run-timeout","status":"timeout"}"""
},
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -57,6 +227,7 @@ class ChatControllerTerminalAckTest {
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -104,6 +275,7 @@ class ChatControllerTerminalAckTest {
else -> "{}"
}
},
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -150,6 +322,7 @@ class ChatControllerTerminalAckTest {
else -> "{}"
}
},
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -181,6 +354,7 @@ class ChatControllerTerminalAckTest {
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -208,6 +382,7 @@ class ChatControllerTerminalAckTest {
requestGateway = { _, _ ->
throw GatewayRequestRejected(GatewaySession.ErrorShape("INVALID_REQUEST", "message rejected"))
},
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -228,6 +403,7 @@ class ChatControllerTerminalAckTest {
scope = this,
json = json,
requestGateway = { _, _ -> throw GatewayRequestNotEnqueued("not connected") },
currentDefaultAgentId = { "main" },
)
controller.handleGatewayEvent("health", null)
@@ -4,6 +4,7 @@ import androidx.room.Room
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -31,9 +32,17 @@ class RoomChatCommandOutboxTest {
gatewayId: String = "gateway-a",
sessionKey: String = "main",
thinkingLevel: String = "off",
ownerAgentId: String = "main",
): ChatOutboxItem {
val result =
enqueue(gatewayId = gatewayId, sessionKey = sessionKey, text = text, thinkingLevel = thinkingLevel, nowMs = nowMs)
enqueue(
gatewayId = gatewayId,
sessionKey = sessionKey,
text = text,
thinkingLevel = thinkingLevel,
nowMs = nowMs,
ownerAgentId = ownerAgentId,
)
return (result as ChatOutboxEnqueueResult.Queued).item
}
@@ -50,11 +59,100 @@ class RoomChatCommandOutboxTest {
assertEquals(listOf("first", "second", "third"), loaded.map { it.text })
assertTrue(loaded.all { it.status == ChatOutboxStatus.Queued && it.retryCount == 0 && it.lastError == null })
assertEquals(listOf("main", "main", "main"), loaded.map { it.sessionKey })
assertEquals(listOf("main", "main", "main"), loaded.map { it.ownerAgentId })
// Enqueue-time thinking level survives the round trip.
assertEquals(listOf("high", "off", "off"), loaded.map { it.thinkingLevel })
assertEquals(loaded.map { it.createdAtMs }.sorted(), loaded.map { it.createdAtMs })
}
@Test
fun callerSuppliedIdempotencyKeyCanReconcileComposerAdmissionAfterRestart() =
runTest {
val result =
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "agent:main:device",
text = "send once",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
idempotencyKey = "composer-command-a",
) as ChatOutboxEnqueueResult.Queued
assertEquals("composer-command-a", result.item.id)
assertTrue(store.wasAdmitted("composer-command-a"))
store.delete("composer-command-a")
assertTrue(store.wasAdmitted("composer-command-a"))
assertFalse(store.wasAdmitted("never-admitted"))
}
@Test
fun admissionReceiptsStayBoundedAcrossSessionsForOneRoutingOwner() =
runTest {
repeat(OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER + 2) { index ->
val id = "composer-command-$index"
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "agent:main:device-$index",
text = "message $index",
thinkingLevel = "off",
nowMs = index.toLong(),
ownerAgentId = "main",
idempotencyKey = id,
)
store.delete(id)
}
assertFalse(store.wasAdmitted("composer-command-0"))
assertFalse(store.wasAdmitted("composer-command-1"))
repeat(OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER) { offset ->
assertTrue(store.wasAdmitted("composer-command-${offset + 2}"))
}
}
@Test
fun activeAdmissionReceiptSurvivesFallbackPruningUntilCommandRetires() =
runTest {
val protectedId = "active-checkpoint"
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "agent:main:protected",
text = "still pending",
thinkingLevel = "off",
nowMs = 0,
ownerAgentId = "main",
idempotencyKey = protectedId,
)
repeat(OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER + 2) { index ->
val id = "retired-command-$index"
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "agent:main:device-$index",
text = "message $index",
thinkingLevel = "off",
nowMs = index.toLong() + 1,
ownerAgentId = "main",
idempotencyKey = id,
)
store.delete(id)
}
store.delete(protectedId)
assertTrue(store.wasAdmitted(protectedId))
val nextId = "next-retired-command"
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "agent:main:next",
text = "advance the recovery window",
thinkingLevel = "off",
nowMs = 100,
ownerAgentId = "main",
idempotencyKey = nextId,
)
store.delete(nextId)
assertFalse(store.wasAdmitted(protectedId))
}
@Test
fun enqueueRefusesBeyondMaxQueued() =
runTest {
@@ -63,7 +161,14 @@ class RoomChatCommandOutboxTest {
}
val refused =
store.enqueue(gatewayId = "gateway-a", sessionKey = "main", text = "overflow", thinkingLevel = "off", nowMs = 999)
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "main",
text = "overflow",
thinkingLevel = "off",
nowMs = 999,
ownerAgentId = "main",
)
assertEquals(ChatOutboxEnqueueResult.QueueFull, refused)
assertEquals(OUTBOX_MAX_QUEUED, store.load("gateway-a").size)
@@ -186,7 +291,14 @@ class RoomChatCommandOutboxTest {
runTest {
assertEquals(
ChatOutboxEnqueueResult.Unavailable,
store.enqueue(gatewayId = " ", sessionKey = "main", text = "hi", thinkingLevel = "off", nowMs = 1),
store.enqueue(
gatewayId = " ",
sessionKey = "main",
text = "hi",
thinkingLevel = "off",
nowMs = 1,
ownerAgentId = "main",
),
)
assertEquals(emptyList<ChatOutboxItem>(), store.load(" "))
@@ -197,12 +309,31 @@ class RoomChatCommandOutboxTest {
@Test
fun deleteForSessionRemovesOnlyThatSessionsRows() =
runTest {
store.enqueueQueued("for main", nowMs = 10)
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "main",
text = "for main",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
idempotencyKey = "main-admission",
)
store.enqueueQueued("for other", nowMs = 20, sessionKey = "agent:other:main")
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "main",
text = "other owner",
thinkingLevel = "off",
nowMs = 30,
ownerAgentId = "other",
idempotencyKey = "other-owner-admission",
)
store.deleteForSession("gateway-a", "main")
store.deleteForSession("gateway-a", "main", "main")
assertEquals(listOf("for other"), store.load("gateway-a").map { it.text })
assertEquals(listOf("for other", "other owner"), store.load("gateway-a").map { it.text })
assertFalse(store.wasAdmitted("main-admission"))
assertTrue(store.wasAdmitted("other-owner-admission"))
}
private fun payload(
@@ -226,6 +357,7 @@ class RoomChatCommandOutboxTest {
text = "with media",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
attachments =
listOf(
payload(big, fileName = "big.jpg"),
@@ -254,6 +386,7 @@ class RoomChatCommandOutboxTest {
text = "too big",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
attachments = listOf(payload(oversized)),
)
assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, refused)
@@ -274,6 +407,7 @@ class RoomChatCommandOutboxTest {
text = "bulk $index",
thinkingLevel = "off",
nowMs = index.toLong(),
ownerAgentId = "main",
attachments = listOf(payload(chunk)),
)
if (result !is ChatOutboxEnqueueResult.Queued) {
@@ -294,11 +428,38 @@ class RoomChatCommandOutboxTest {
text = "fits again",
thinkingLevel = "off",
nowMs = 999,
ownerAgentId = "main",
attachments = listOf(payload(chunk)),
)
assertTrue(retried is ChatOutboxEnqueueResult.Queued)
}
@Test
fun conditionalDeleteNeverRemovesAClaimedRow() =
runTest {
val first =
(
store.enqueue(
gatewayId = "gateway-a",
sessionKey = "main",
text = "delete queued",
thinkingLevel = "off",
nowMs = 1,
ownerAgentId = "main",
idempotencyKey = "rollback-receipt",
) as ChatOutboxEnqueueResult.Queued
).item
assertTrue(store.wasAdmitted("rollback-receipt"))
assertTrue(store.deleteIfQueued(first.id))
assertTrue(store.load("gateway-a").isEmpty())
assertFalse(store.wasAdmitted("rollback-receipt"))
val claimed = store.enqueueQueued(text = "already claimed", nowMs = 2)
assertEquals(1, store.claimForSending(claimed.id, retryCount = 0, lastError = null))
assertFalse(store.deleteIfQueued(claimed.id))
assertEquals(ChatOutboxStatus.Sending, store.load("gateway-a").single().status)
}
@Test
fun confirmDeliveredRetiresRowsAndTheirAttachmentBytesAtomically() =
runTest {
@@ -310,6 +471,7 @@ class RoomChatCommandOutboxTest {
text = "confirmed",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
attachments = listOf(payload(bytes)),
) as ChatOutboxEnqueueResult.Queued
store.updateStatus(queued.item.id, ChatOutboxStatus.Accepted, retryCount = 0, lastError = null)
@@ -331,6 +493,7 @@ class RoomChatCommandOutboxTest {
text = "a",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
attachments = listOf(payload(byteArrayOf(1))),
) as ChatOutboxEnqueueResult.Queued
val b =
@@ -340,10 +503,11 @@ class RoomChatCommandOutboxTest {
text = "b",
thinkingLevel = "off",
nowMs = 20,
ownerAgentId = "main",
attachments = listOf(payload(byteArrayOf(2))),
) as ChatOutboxEnqueueResult.Queued
store.deleteForSession("gateway-b", "other")
store.deleteForSession("gateway-b", "other", "main")
store.clearGateway("gateway-a")
assertTrue(store.load("gateway-a").isEmpty())
@@ -370,6 +534,7 @@ class RoomChatCommandOutboxTest {
text = "/clear",
thinkingLevel = "off",
nowMs = 10,
ownerAgentId = "main",
gatedEpoch = 7L,
) as ChatOutboxEnqueueResult.Queued
assertEquals(7L, store.load("gateway-a").single().gatedEpoch)
@@ -46,6 +46,7 @@ class RoomChatTranscriptCacheTest {
val imagePart = ChatMessageContent(type = "image", mimeType = "image/png", fileName = "a.png", base64 = "AAAA")
store.saveTranscript(
gatewayId = "gateway-a",
agentId = "main",
sessionKey = "main",
messages =
listOf(
@@ -56,7 +57,7 @@ class RoomChatTranscriptCacheTest {
),
)
val loaded = store.loadTranscript("gateway-a", "main")
val loaded = store.loadTranscript("gateway-a", "main", "main")
assertEquals(listOf("hello", "world"), loaded.map { it.content.single().text })
assertTrue(loaded.all { message -> message.content.all { part -> part.type == "text" && part.base64 == null } })
@@ -65,12 +66,29 @@ class RoomChatTranscriptCacheTest {
assertEquals(listOf("run-1:user", null), loaded.map { it.idempotencyKey })
}
@Test
fun lastDefaultOwnerIsGatewayScopedAndClearedWithItsCache() =
runTest {
val store = cache()
store.saveLastDefaultAgentId("gateway-a", "agent-a")
store.saveLastDefaultAgentId("gateway-b", "agent-b")
assertEquals("agent-a", store.loadLastDefaultAgentId("gateway-a"))
assertEquals("agent-b", store.loadLastDefaultAgentId("gateway-b"))
store.clearGateway("gateway-a")
assertEquals(null, store.loadLastDefaultAgentId("gateway-a"))
assertEquals("agent-b", store.loadLastDefaultAgentId("gateway-b"))
}
@Test
fun transcriptRoundTripDropsInternalRoleRows() =
runTest {
val store = cache()
store.saveTranscript(
gatewayId = "gateway-a",
agentId = "main",
sessionKey = "main",
messages =
listOf(
@@ -81,7 +99,7 @@ class RoomChatTranscriptCacheTest {
),
)
val loaded = store.loadTranscript("gateway-a", "main")
val loaded = store.loadTranscript("gateway-a", "main", "main")
assertEquals(listOf("hello", "visible plugin notice", "reply"), loaded.map { it.content.single().text })
assertEquals(listOf("user", "custom", "assistant"), loaded.map { it.role })
@@ -93,11 +111,12 @@ class RoomChatTranscriptCacheTest {
val store = cache()
store.saveTranscript(
gatewayId = "gateway-a",
agentId = "main",
sessionKey = "main",
messages = (0 until MAX_CACHED_MESSAGES_PER_SESSION + 50).map { index -> message("m$index", timestampMs = index.toLong()) },
)
val loadedTexts = store.loadTranscript("gateway-a", "main").map { it.content.single().text }
val loadedTexts = store.loadTranscript("gateway-a", "main", "main").map { it.content.single().text }
assertEquals(MAX_CACHED_MESSAGES_PER_SESSION, loadedTexts.size)
assertEquals("m50", loadedTexts.first())
@@ -108,24 +127,25 @@ class RoomChatTranscriptCacheTest {
fun sessionWriteEvictsBeyondBoundAndDropsOrphanedTranscripts() =
runTest {
val store = cache()
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "session-10", messages = listOf(message("kept")))
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "session-55", messages = listOf(message("evicted")))
store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "session-10", messages = listOf(message("kept")))
store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "session-55", messages = listOf(message("evicted")))
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions =
(0 until MAX_CACHED_SESSIONS + 10).map { index ->
ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index, displayName = "Session $index")
},
)
val sessions = store.loadSessions("gateway-a")
val sessions = store.loadSessions("gateway-a", "main")
assertEquals(MAX_CACHED_SESSIONS, sessions.size)
assertEquals("session-0", sessions.first().key)
assertEquals("session-${MAX_CACHED_SESSIONS - 1}", sessions.last().key)
assertEquals("Session 0", sessions.first().displayName)
assertEquals(listOf("kept"), store.loadTranscript("gateway-a", "session-10").map { it.content.single().text })
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "session-55"))
assertEquals(listOf("kept"), store.loadTranscript("gateway-a", "main", "session-10").map { it.content.single().text })
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main", "session-55"))
}
@Test
@@ -134,20 +154,48 @@ class RoomChatTranscriptCacheTest {
val store = cache()
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions =
(0 until MAX_CACHED_SESSIONS).map { index ->
ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index)
},
)
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "deep-session", messages = listOf(message("deep text")))
store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "deep-session", messages = listOf(message("deep text")))
assertEquals(listOf("deep text"), store.loadTranscript("gateway-a", "deep-session").map { it.content.single().text })
val sessionKeys = store.loadSessions("gateway-a").map { it.key }
assertEquals(listOf("deep text"), store.loadTranscript("gateway-a", "main", "deep-session").map { it.content.single().text })
val sessionKeys = store.loadSessions("gateway-a", "main").map { it.key }
assertEquals(MAX_CACHED_SESSIONS, sessionKeys.size)
assertTrue(sessionKeys.contains("deep-session"))
}
@Test
fun sessionCacheIsBoundedAcrossEveryAgentInOneGateway() =
runTest {
val store = cache()
repeat(MAX_CACHED_SESSIONS + 1) { index ->
store.saveTranscript(
gatewayId = "gateway-a",
agentId = "agent-$index",
sessionKey = "main",
messages = listOf(message("message-$index")),
)
}
val cachedSessionCount =
(0..MAX_CACHED_SESSIONS).sumOf { index ->
store.loadSessions("gateway-a", "agent-$index").size
}
assertEquals(MAX_CACHED_SESSIONS, cachedSessionCount)
assertTrue(store.loadTranscript("gateway-a", "agent-0", "main").isEmpty())
assertEquals(
listOf("message-$MAX_CACHED_SESSIONS"),
store
.loadTranscript("gateway-a", "agent-$MAX_CACHED_SESSIONS", "main")
.map { it.content.single().text },
)
}
@Test
fun activeDeepTranscriptSurvivesSessionListRefresh() =
runTest {
@@ -156,24 +204,26 @@ class RoomChatTranscriptCacheTest {
(0 until MAX_CACHED_SESSIONS).map { index ->
ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index)
}
store.saveSessions(gatewayId = "gateway-a", sessions = listedSessions)
store.saveSessions(gatewayId = "gateway-a", agentId = "main", sessions = listedSessions)
store.saveTranscript(
gatewayId = "gateway-a",
agentId = "main",
sessionKey = "deep-session",
messages = listOf(message("deep text")),
)
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions = listedSessions,
retainedSessionKey = "deep-session",
)
assertEquals(MAX_CACHED_SESSIONS, store.loadSessions("gateway-a").size)
assertTrue(store.loadSessions("gateway-a").any { it.key == "deep-session" })
assertEquals(MAX_CACHED_SESSIONS, store.loadSessions("gateway-a", "main").size)
assertTrue(store.loadSessions("gateway-a", "main").any { it.key == "deep-session" })
assertEquals(
listOf("deep text"),
store.loadTranscript("gateway-a", "deep-session").map { it.content.single().text },
store.loadTranscript("gateway-a", "main", "deep-session").map { it.content.single().text },
)
}
@@ -183,21 +233,24 @@ class RoomChatTranscriptCacheTest {
val store = cache()
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions = listOf(ChatSessionEntry(key = "deep-session", updatedAtMs = 1)),
)
store.saveTranscript(
gatewayId = "gateway-a",
agentId = "main",
sessionKey = "deep-session",
messages = listOf(message("deleted remotely")),
)
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = 2)),
)
assertEquals(listOf("main"), store.loadSessions("gateway-a").map { it.key })
assertTrue(store.loadTranscript("gateway-a", "deep-session").isEmpty())
assertEquals(listOf("main"), store.loadSessions("gateway-a", "main").map { it.key })
assertTrue(store.loadTranscript("gateway-a", "main", "deep-session").isEmpty())
}
@Test
@@ -206,49 +259,83 @@ class RoomChatTranscriptCacheTest {
val store = cache()
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions =
listOf(
ChatSessionEntry(key = "main", updatedAtMs = 1),
ChatSessionEntry(key = "other", updatedAtMs = 2),
),
)
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("delete me")))
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "other", messages = listOf(message("keep me")))
store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "main", messages = listOf(message("delete me")))
store.saveTranscript(gatewayId = "gateway-a", agentId = "other", sessionKey = "main", messages = listOf(message("delete me too")))
store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "other", messages = listOf(message("keep me")))
store.deleteSession("gateway-a", "main")
store.deleteSession("gateway-a", "main", "main")
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
assertEquals(listOf("other"), store.loadSessions("gateway-a").map { it.key })
assertEquals(listOf("keep me"), store.loadTranscript("gateway-a", "other").map { it.content.single().text })
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main", "main"))
assertEquals(listOf("delete me too"), store.loadTranscript("gateway-a", "other", "main").map { it.content.single().text })
assertEquals(listOf("other"), store.loadSessions("gateway-a", "main").map { it.key })
assertEquals(listOf("keep me"), store.loadTranscript("gateway-a", "main", "other").map { it.content.single().text })
}
@Test
fun transcriptsAreScopedToGatewayIdentity() =
runTest {
val store = cache()
store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("gateway a text")))
store.saveSessions("gateway-a", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "main", messages = listOf(message("gateway a text")))
store.saveSessions("gateway-a", "main", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-b", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-b"))
store.saveTranscript(gatewayId = "gateway-b", sessionKey = "main", messages = listOf(message("gateway b text")))
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-b", "main", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-b", "main"))
store.saveTranscript(gatewayId = "gateway-b", agentId = "main", sessionKey = "main", messages = listOf(message("gateway b text")))
assertEquals(listOf("gateway a text"), store.loadTranscript("gateway-a", "main").map { it.content.single().text })
assertEquals(listOf("main"), store.loadSessions("gateway-a").map { it.key })
assertEquals(listOf("gateway a text"), store.loadTranscript("gateway-a", "main", "main").map { it.content.single().text })
assertEquals(listOf("main"), store.loadSessions("gateway-a", "main").map { it.key })
}
@Test
fun blankGatewayIdentityDisablesReadsAndWrites() =
runTest {
val store = cache()
store.saveTranscript(gatewayId = "", sessionKey = "main", messages = listOf(message("must not persist")))
store.saveSessions("", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
store.saveTranscript(gatewayId = "", agentId = "main", sessionKey = "main", messages = listOf(message("must not persist")))
store.saveSessions("", "main", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1)))
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions(""))
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("", "main", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("", "main"))
// Nothing was written under a fallback scope either.
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-a"))
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-a", "main"))
}
@Test
fun transcriptsAreScopedToAgentOwnership() =
runTest {
val store = cache()
store.saveTranscript("gateway-a", "agent-a", "custom", listOf(message("agent a text")))
store.saveTranscript("gateway-a", "agent-b", "custom", listOf(message("agent b text")))
store.saveSessions(
"gateway-a",
"agent-a",
listOf(ChatSessionEntry(key = "agent-a-session", updatedAtMs = 1)),
retainedSessionKey = "custom",
)
store.saveSessions(
"gateway-a",
"agent-b",
listOf(ChatSessionEntry(key = "agent-b-session", updatedAtMs = 2)),
retainedSessionKey = "custom",
)
assertEquals(
listOf("agent a text"),
store.loadTranscript("gateway-a", "agent-a", "custom").map { it.content.single().text },
)
assertEquals(
listOf("agent b text"),
store.loadTranscript("gateway-a", "agent-b", "custom").map { it.content.single().text },
)
assertEquals(listOf("agent-a-session", "custom"), store.loadSessions("gateway-a", "agent-a").map { it.key })
assertEquals(listOf("agent-b-session", "custom"), store.loadSessions("gateway-a", "agent-b").map { it.key })
}
}
@@ -231,12 +231,14 @@ class VoiceNoteRecorderControllerTest {
controller.finish()
assertEquals(VoiceNoteRecorderState.Preparing, controller.state.value)
assertTrue(finished.single().file.exists())
assertTrue(controller.canCommitPreparation(finished.single().id))
// Composition-scoped staging may be cancelled before it runs; cancel()
// must still delete the handed-off recording.
controller.cancel()
assertEquals(VoiceNoteRecorderState.Idle, controller.state.value)
assertFalse(controller.canCommitPreparation(finished.single().id))
assertFalse(finished.single().file.exists())
directory.deleteRecursively()
}
@@ -15,6 +15,50 @@ class SessionsScreenGroupingTest {
assertEquals("2d", relativeSessionTime(updatedAtMs = now - 2 * 24 * 60 * 60_000L, nowMs = now))
}
@Test
fun sessionActionTargetKeepsTheOwnerCapturedWhenTheDialogOpened() {
val target =
ChatSessionEntry(
key = "custom",
updatedAtMs = null,
ownerAgentId = "agent-a",
label = "Original",
).toActionTarget("gateway-a")
val refreshed =
ChatSessionEntry(
key = "custom",
updatedAtMs = null,
ownerAgentId = "agent-b",
label = "Replacement",
)
assertEquals("gateway-a", target.gatewayStableId)
assertEquals("agent-a", target.ownerAgentId)
assertEquals("Original", target.label)
assertEquals("gateway-a:agent-a:custom", target.stateKey)
assertEquals(true, target.matchesGateway("gateway-a"))
assertEquals(false, target.matchesGateway("gateway-b"))
assertEquals("agent-b", refreshed.ownerAgentId)
}
@Test
fun sessionActionTargetSavedStatePreservesOwnerAndNullableLabels() {
val full = SessionActionTarget("gateway-a", "custom", "agent-a", "", "Display")
val sparse = SessionActionTarget(null, "agent:main:device", null, null, null)
assertEquals(full, sessionActionTargetFromSavedState(full.toSavedState()))
assertEquals(sparse, sessionActionTargetFromSavedState(sparse.toSavedState()))
}
@Test
fun sessionActionTargetSavedStateRejectsMissingIdentity() {
assertEquals(null, sessionActionTargetFromSavedState(emptyList()))
assertEquals(
null,
sessionActionTargetFromSavedState(listOf("1", "gateway-a", "", "0", "", "0", "", "0", "")),
)
}
@Test
fun groupsPinnedThenAlphabeticalCategoriesThenUngrouped() {
val sections =
@@ -3,8 +3,14 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.ChatDraft
import ai.openclaw.app.ChatDraftPlacement
import ai.openclaw.app.ChatShareDraft
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.GatewayDefaultAgentOwner
import ai.openclaw.app.chat.VoiceNoteRecorderState
import ai.openclaw.app.chat.resolveChatComposerOwner
import ai.openclaw.app.chat.resolveChatComposerRoutingOwner
import ai.openclaw.app.claimChatDraftForOwner
import android.net.Uri
import androidx.compose.runtime.saveable.SaverScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
@@ -19,6 +25,390 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ChatComposerDraftTest {
@Test
fun textDraftsRemainKeyedToTheirComposerOwner() {
val store = ChatComposerTextDraftStore()
val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first")
val second = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:second")
store[first] = "first draft"
store[second] = "second draft"
assertEquals("first draft", store[first])
assertEquals("second draft", store[second])
}
@Test
fun sendPayloadReadsCurrentOwnerStoresAfterEditsAndRemovals() {
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first")
val textDrafts = ChatComposerTextDraftStore()
val attachments = ChatComposerAttachmentStore()
val removed = PendingAttachment("removed", "removed.jpg", "image/jpeg", "YQ==")
val retained = PendingAttachment("retained", "retained.jpg", "image/jpeg", "Yg==")
textDrafts[owner] = "old text"
attachments.add(owner, listOf(removed))
textDrafts[owner] = " edited text "
attachments.remove(owner, setOf(removed.id))
attachments.add(owner, listOf(retained))
val payload = captureChatComposerSendPayload(owner, textDrafts, attachments)
assertEquals(" edited text ", payload.inputSnapshot)
assertEquals("edited text", payload.message)
assertEquals(listOf(retained), payload.attachments)
}
@Test
fun textDraftSnapshotRestoresEveryOwnerAfterProcessRecreation() {
var saved = arrayListOf<String>()
val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first")
val second = ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "work", sessionKey = "agent:work:second")
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
store[first] = "first draft"
store[second] = "second draft"
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))
assertEquals("first draft", restored[first])
assertEquals("second draft", restored[second])
}
@Test
fun processRecreationHidesPendingDraftUntilOutboxReconciliation() {
var saved = arrayListOf<String>()
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
store[owner] = "send once"
store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once")
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))
assertEquals("", restored[owner])
assertEquals(listOf("command-a"), restored.pendingAdmissions().map { it.commandId })
restored.resolveAdmission("command-a", admitted = false)
assertEquals("send once", restored[owner])
}
@Test
fun oversizedPendingDraftIsNotHiddenWithoutACompleteCrashCheckpoint() {
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val oversized = "x".repeat(CHAT_COMPOSER_MAX_SEND_CHARS + 1)
val store = ChatComposerTextDraftStore()
store[owner] = oversized
assertFalse(store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = oversized))
assertEquals(oversized, store[owner])
assertTrue(store.pendingAdmissions().isEmpty())
}
@Test
fun pendingDraftBudgetIncludesOtherOwnersAdmissions() {
val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first")
val second = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:second")
val third = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:third")
val fourth = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:fourth")
val store = ChatComposerTextDraftStore()
store[first] = "a".repeat(CHAT_COMPOSER_MAX_SEND_CHARS)
store[second] = "b".repeat(CHAT_COMPOSER_MAX_SEND_CHARS)
store[third] = "c".repeat(CHAT_COMPOSER_MAX_SEND_CHARS)
store[fourth] = "d".repeat(10_000)
assertTrue(store.beginAdmission(commandId = "command-a", owner = first, inputSnapshot = store[first]))
assertTrue(store.beginAdmission(commandId = "command-b", owner = second, inputSnapshot = store[second]))
assertTrue(store.beginAdmission(commandId = "command-c", owner = third, inputSnapshot = store[third]))
assertFalse(store.beginAdmission(commandId = "command-d", owner = fourth, inputSnapshot = store[fourth]))
assertEquals("", store[first])
assertEquals("", store[second])
assertEquals("", store[third])
assertEquals("d".repeat(10_000), store[fourth])
assertEquals(
listOf("command-a", "command-b", "command-c"),
store.pendingAdmissions().map(PendingChatComposerSend::commandId),
)
}
@Test
fun restoredPendingAdmissionMigratesWithoutAVisibleDraft() {
var saved = arrayListOf<String>()
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
store[alias] = "send once"
store.beginAdmission(commandId = "command-a", owner = alias, inputSnapshot = "send once")
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))
assertEquals("", restored[alias])
assertEquals(setOf(alias), restored.migrateMatching(canonical, canonical.sessionKey))
assertEquals(canonical, restored.pendingAdmissions().single().owner)
restored.resolveAdmission("command-a", admitted = false)
assertEquals("", restored[alias])
assertEquals("send once", restored[canonical])
}
@Test
fun acceptedAliasAdmissionKeepsTheCanonicalDraftMergedBeforeResolution() {
var saved = arrayListOf<String>()
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
store[alias] = "already sent"
store[canonical] = "keep editing"
store.beginAdmission(commandId = "command-a", owner = alias, inputSnapshot = "already sent")
store.migrate(alias, canonical)
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))
assertEquals("keep editing", restored[canonical])
restored.resolveAdmission("command-a", admitted = true)
assertEquals("keep editing", restored[canonical])
}
@Test
fun rejectedAliasAdmissionRestoresSentTextAfterTheCanonicalDraft() {
var saved = arrayListOf<String>()
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
store[alias] = "retry me"
store[canonical] = "keep editing"
store.beginAdmission(commandId = "command-a", owner = alias, inputSnapshot = "retry me")
store.migrate(alias, canonical)
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))
restored.resolveAdmission("command-a", admitted = false)
assertEquals("retry me\n\nkeep editing", restored[canonical])
}
@Test
fun removingGatewayDraftsAlsoRemovesItsPendingAdmission() {
val removed = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val retained = ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "main", sessionKey = "main")
val store = ChatComposerTextDraftStore()
store[removed] = "private a"
store[retained] = "private b"
store.beginAdmission(commandId = "command-a", owner = removed, inputSnapshot = "private a")
store.removeOwners { it.gatewayStableId == "gateway-a" }
assertEquals("", store[removed])
assertEquals("private b", store[retained])
assertTrue(store.pendingAdmissions().isEmpty())
}
@Test
fun durablePendingSendStaysHiddenAndLaterEditsSurviveReconciliation() {
var saved = arrayListOf<String>()
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
store[owner] = "send once"
store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once")
assertEquals("", store[owner])
store[owner] = "new draft"
val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))
assertEquals("new draft", restored[owner])
restored.resolveAdmission("command-a", admitted = true)
assertEquals("new draft", restored[owner])
assertTrue(restored.pendingAdmissions().isEmpty())
}
@Test
fun identicallyRetypedDraftSurvivesAcceptedAdmission() {
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val store = ChatComposerTextDraftStore()
store[owner] = "send once"
store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once")
store[owner] = "send once"
store.resolveAdmission("command-a", admitted = true)
assertEquals("send once", store[owner])
}
@Test
fun rejectedPendingSendRestoresOriginalBeforePostAdmissionEdits() {
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val store = ChatComposerTextDraftStore()
store[owner] = "send once"
store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once")
store[owner] = "new draft"
store.resolveAdmission("command-a", admitted = false)
assertEquals("send once\n\nnew draft", store[owner])
}
@Test
fun pendingReplyDraftClaimsTheCanonicalMainAliasOwner() {
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
val draft = ChatDraft(text = "reply", placement = ChatDraftPlacement.BeforeExisting, owner = alias)
val claimed = claimChatDraftForOwner(draft, canonical, canonical.sessionKey)
assertEquals(canonical, claimed?.owner)
assertEquals("reply", claimed?.text)
}
@Test
fun textDraftStoreEvictsTheOldestOwnerAndBoundsProcessCheckpoint() {
var saved = arrayListOf<String>()
val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it })
val owners =
(0..CHAT_COMPOSER_MAX_DRAFT_OWNERS).map { index ->
ChatComposerOwner(
gatewayStableId = "gateway-a",
agentId = "main",
sessionKey = "agent:main:$index",
)
}
val longDraft = "x".repeat(40_000)
owners.forEach { owner -> store[owner] = longDraft }
assertEquals(CHAT_COMPOSER_MAX_DRAFT_OWNERS, store.size())
assertEquals("", store[owners.first()])
assertEquals(longDraft, store[owners.last()])
assertTrue(saved.sumOf(String::length) <= CHAT_COMPOSER_DRAFT_SNAPSHOT_MAX_CHARS)
assertEquals(longDraft, ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))[owners.last()])
}
@Test
fun mainAliasDraftMovesToCanonicalMainOwner() {
val store = ChatComposerTextDraftStore()
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
store[alias] = "typed while connecting"
assertTrue(shouldMigrateComposerDraft(alias, canonical, canonical.sessionKey))
store.migrate(from = alias, to = canonical)
assertEquals("", store[alias])
assertEquals("typed while connecting", store[canonical])
}
@Test
fun mainAliasDraftDoesNotCrossGatewayOrAgent() {
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
assertFalse(
shouldMigrateComposerDraft(
alias,
ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "main", sessionKey = "agent:main:device"),
"agent:main:device",
),
)
assertFalse(
shouldMigrateComposerDraft(
alias,
ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "other", sessionKey = "agent:other:device"),
"agent:other:device",
),
)
}
@Test
fun mainAliasMigrationPreservesAnExistingCanonicalDraft() {
val store = ChatComposerTextDraftStore()
val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device")
store[alias] = "typed while connecting"
store[canonical] = "saved canonical draft"
store.migrate(from = alias, to = canonical)
assertEquals("saved canonical draft\n\ntyped while connecting", store[canonical])
}
@Test
fun gatewayBoundProvisionalDraftMovesToItsVerifiedOwner() {
val store = ChatComposerTextDraftStore()
val provisional =
ChatComposerOwner(
gatewayStableId = "gateway-a",
agentId = "main",
sessionKey = "main",
routingVerified = false,
)
val verified = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "work", sessionKey = "agent:work:device")
store[provisional] = "typed before gateway hello"
assertTrue(shouldMigrateComposerDraft(provisional, verified, verified.sessionKey))
store.migrate(provisional, verified)
assertEquals("", store[provisional])
assertEquals("typed before gateway hello", store[verified])
}
@Test
fun provisionalOwnerCheckpointSurvivesRecreation() {
val provisional =
ChatComposerOwner(
gatewayStableId = null,
agentId = "main",
sessionKey = "main",
routingVerified = false,
)
val restored = chatComposerOwnerFromCheckpointValues(provisional.toCheckpointValues())
assertEquals(provisional, restored)
}
@Test
fun ownerlessProvisionalDraftMovesWhenAGatewayIsSelected() {
val unresolvedGateway =
ChatComposerOwner(
gatewayStableId = null,
agentId = "main",
sessionKey = "custom",
)
val resolvedGateway = unresolvedGateway.copy(gatewayStableId = "gateway-a", routingVerified = true)
assertTrue(shouldMigrateComposerDraft(unresolvedGateway, resolvedGateway, "agent:main:device"))
}
@Test
fun ownerlessProvisionalDraftWaitsForVerifiedGatewayRouting() {
val unresolvedGateway =
ChatComposerOwner(
gatewayStableId = null,
agentId = "main",
sessionKey = "custom",
)
val selectedGateway = unresolvedGateway.copy(gatewayStableId = "gateway-a", agentId = "other")
assertFalse(shouldMigrateComposerDraft(unresolvedGateway, selectedGateway, "agent:other:device"))
}
@Test
fun verifiedOwnerlessDraftDoesNotCrossAgentsWhenAGatewayIsSelected() {
val captured =
ChatComposerOwner(
gatewayStableId = null,
agentId = "agent-a",
sessionKey = "custom",
routingVerified = true,
)
val current = captured.copy(gatewayStableId = "gateway-a", agentId = "agent-b")
assertFalse(shouldMigrateComposerDraft(captured, current, "agent:agent-b:device"))
}
@Test
fun verifiedDraftDoesNotMoveWhenTheDefaultOwnerChanges() {
val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "first", sessionKey = "custom")
val second = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "second", sessionKey = "custom")
assertFalse(shouldMigrateComposerDraft(first, second, "agent:second:device"))
}
@Test
fun replyDraftPreservesExistingComposerText() {
val draft = ChatDraft(text = "> quoted\n\n", placement = ChatDraftPlacement.BeforeExisting)
@@ -33,6 +423,21 @@ class ChatComposerDraftTest {
assertEquals("repeat this", mergeChatDraft(draft, "existing text"))
}
@Test
fun replyDraftCanOnlyMergeIntoItsOriginatingOwner() {
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a")
val draft = ChatDraft(text = "> quoted\n\n", placement = ChatDraftPlacement.BeforeExisting, owner = owner)
assertEquals(
null,
mergeChatDraft(draft = draft, currentInput = "wrong", currentOwner = owner.copy(sessionKey = "session-b")),
)
assertEquals(
"> quoted\n\nreply",
mergeChatDraft(draft = draft, currentInput = "reply", currentOwner = owner),
)
}
@Test
fun sharedTextPreservesExistingComposerText() {
assertEquals(
@@ -58,6 +463,8 @@ class ChatComposerDraftTest {
@Test
fun stagedSharePreservesComposerAndReportsDroppedImages() {
val owner = ChatComposerOwner("gateway", "main", "agent:main:device")
val store = ChatComposerAttachmentStore()
val existing = pendingAttachment("existing")
val shared = pendingAttachment("shared")
val staged =
@@ -68,17 +475,12 @@ class ChatComposerDraftTest {
droppedImageCount = 2,
)
val merged =
mergeStagedChatShare(
staged = staged,
currentInput = "existing draft",
currentAttachments = listOf(existing),
)
store.add(owner, listOf(existing))
val omitted = store.add(owner, staged.attachments)
assertEquals("existing draft\n\nshared link", merged.input)
assertEquals(listOf(existing, shared), merged.attachments)
assertEquals(0, merged.failedImageCount)
assertEquals(2, merged.droppedImageCount)
assertEquals("existing draft\n\nshared link", mergeSharedChatText(staged.text, "existing draft"))
assertEquals(listOf(existing, shared), store.get(owner))
assertEquals(2, staged.failedImageCount + staged.droppedImageCount + omitted)
}
@Test
@@ -95,7 +497,7 @@ class ChatComposerDraftTest {
)
val staged =
stageChatShareDraft(draft, currentAttachments = emptyList()) { uri ->
stageChatShareDraft(draft) { uri ->
if (uri == unreadable) error("provider read failed")
pendingAttachment(uri.toString())
}
@@ -118,7 +520,7 @@ class ChatComposerDraftTest {
assertThrows(CancellationException::class.java) {
runBlocking {
stageChatShareDraft(draft, currentAttachments = emptyList()) { throw CancellationException("screen disposed") }
stageChatShareDraft(draft) { throw CancellationException("screen disposed") }
}
}
}
@@ -126,24 +528,29 @@ class ChatComposerDraftTest {
@Test
fun repeatedSharesRespectExistingComposerAttachmentLimit() =
runBlocking {
val owner = ChatComposerOwner("gateway", "main", "agent:main:device")
val store = ChatComposerAttachmentStore()
val current = (1..7).map { pendingAttachment("existing-$it") }
val uris = (1..3).map { Uri.parse("content://photos/shared/$it") }
val draft = ChatShareDraft(id = 1, text = null, imageUris = uris, droppedImageCount = 0)
val staged =
stageChatShareDraft(draft, currentAttachments = current) { uri ->
stageChatShareDraft(draft) { uri ->
pendingAttachment(uri.toString())
}
assertEquals(listOf(uris.first().toString()), staged.attachments.map { it.id })
assertEquals(2, staged.droppedImageCount)
val merged = mergeStagedChatShare(staged, currentInput = "", currentAttachments = current)
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, merged.attachments.size)
assertEquals(2, merged.droppedImageCount)
assertEquals(uris.map(Uri::toString), staged.attachments.map { it.id })
assertEquals(0, staged.droppedImageCount)
store.add(owner, current)
val omitted = store.add(owner, staged.attachments)
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, store.get(owner).size)
assertEquals(2, staged.droppedImageCount + omitted)
}
@Test
fun mergeRechecksAttachmentBudgetAfterStaging() {
val owner = ChatComposerOwner("gateway", "main", "agent:main:device")
val store = ChatComposerAttachmentStore()
val staged =
StagedChatShare(
text = null,
@@ -153,10 +560,26 @@ class ChatComposerDraftTest {
)
val current = (1..7).map { pendingAttachment("existing-$it") }
val merged = mergeStagedChatShare(staged, currentInput = "", currentAttachments = current)
store.add(owner, current)
val omitted = store.add(owner, staged.attachments)
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, merged.attachments.size)
assertEquals(1, merged.droppedImageCount)
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, store.get(owner).size)
assertEquals(1, staged.droppedImageCount + omitted)
}
@Test
fun sharedAttachmentsAtomicallyMergeWithAConcurrentPickerImport() {
val owner = ChatComposerOwner("gateway", "main", "agent:main:device")
val store = ChatComposerAttachmentStore()
val existing = pendingAttachment("existing")
val picker = pendingAttachment("picker")
val shared = pendingAttachment("shared")
store.add(owner, listOf(existing))
store.add(owner, listOf(picker))
store.add(owner, listOf(shared))
assertEquals(listOf(existing, picker, shared), store.get(owner))
}
@Test
@@ -190,10 +613,243 @@ class ChatComposerDraftTest {
fun stagedShareCommitsOnlyForMatchingQueueHead() {
val current = ChatShareDraft(id = 7, text = "current", imageUris = emptyList(), droppedImageCount = 0)
val replacement = ChatShareDraft(id = 8, text = "replacement", imageUris = emptyList(), droppedImageCount = 0)
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a")
assertTrue(canCommitStagedChatShare(stagedId = current.id, currentHead = current))
assertFalse(canCommitStagedChatShare(stagedId = current.id, currentHead = replacement))
assertFalse(canCommitStagedChatShare(stagedId = current.id, currentHead = null))
assertTrue(canCommitStagedChatShare(current.id, current, owner, owner))
assertFalse(canCommitStagedChatShare(current.id, replacement, owner, owner))
assertFalse(canCommitStagedChatShare(current.id, null, owner, owner))
}
@Test
fun asyncComposerResultsCommitOnlyToTheirOriginalOwner() {
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a")
assertTrue(canCommitComposerResult(owner, owner))
assertFalse(canCommitComposerResult(owner, owner.copy(gatewayStableId = "gateway-b")))
assertFalse(canCommitComposerResult(owner, owner.copy(agentId = "agent-b")))
assertFalse(canCommitComposerResult(owner, owner.copy(sessionKey = "session-b")))
}
@Test
fun pendingAttachmentsRemainKeyedAcrossComposerNavigationAndOwnerResolution() {
val ownerA = ChatComposerOwner(gatewayStableId = "gateway", agentId = "agent-a", sessionKey = "session-a")
val ownerB = ChatComposerOwner(gatewayStableId = "gateway", agentId = "agent-b", sessionKey = "session-b")
val resolvedA = ownerA.copy(sessionKey = "agent:agent-a:device")
val store = ChatComposerAttachmentStore()
val first = pendingAttachment("first")
val second = pendingAttachment("second")
val late = pendingAttachment("late")
val importId = store.beginImport(ownerA)
store.add(ownerA, listOf(first))
store.add(ownerB, listOf(second))
assertEquals(listOf(first), store.attachments.value[ownerA])
assertEquals(listOf(second), store.attachments.value[ownerB])
store.migrate(ownerA, resolvedA)
assertEquals(null, store.attachments.value[ownerA])
assertEquals(listOf(first), store.attachments.value[resolvedA])
assertEquals(listOf(second), store.attachments.value[ownerB])
// Only the decode that was already in flight follows the explicit owner migration.
store.completeImport(importId, listOf(late))
assertEquals(listOf(first, late), store.attachments.value[resolvedA])
val reusedProvisional = pendingAttachment("reused")
store.add(ownerA, listOf(reusedProvisional))
assertEquals(listOf(reusedProvisional), store.attachments.value[ownerA])
store.remove(resolvedA, setOf(first.id, late.id))
assertEquals(null, store.attachments.value[resolvedA])
}
@Test
fun removingGatewayAttachmentsAlsoCancelsItsInFlightImports() {
val removed = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main")
val retained = ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "main", sessionKey = "main")
val store = ChatComposerAttachmentStore()
val removedAttachment = pendingAttachment("removed")
val retainedAttachment = pendingAttachment("retained")
val removedImport = store.beginImport(removed)
store.add(removed, listOf(removedAttachment))
store.add(retained, listOf(retainedAttachment))
store.removeOwners { it.gatewayStableId == "gateway-a" }
assertEquals(emptyList<PendingAttachment>(), store.get(removed))
assertEquals(listOf(retainedAttachment), store.get(retained))
assertEquals(null, store.completeImport(removedImport, listOf(pendingAttachment("late"))))
}
@Test
fun ownerResolutionMigratesParkedDraftsAttachmentsAndImportsAfterNavigation() {
val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false)
val unrelated = ChatComposerOwner("gateway", "other", "agent:other:device")
val resolved = ChatComposerOwner("gateway", "work", "agent:work:device")
val drafts = ChatComposerTextDraftStore()
val attachments = ChatComposerAttachmentStore()
val parked = pendingAttachment("parked")
val late = pendingAttachment("late")
val unrelatedAttachment = pendingAttachment("unrelated")
drafts[provisional] = "parked draft"
drafts[unrelated] = "other draft"
attachments.add(provisional, listOf(parked))
attachments.add(unrelated, listOf(unrelatedAttachment))
val importId = attachments.beginImport(provisional)
assertEquals(setOf(provisional), drafts.migrateMatching(resolved, resolved.sessionKey))
val migration = attachments.migrateMatching(resolved, resolved.sessionKey)
attachments.completeImport(importId, listOf(late))
assertEquals(setOf(provisional), migration.sources)
assertEquals(0, migration.omittedCount)
assertEquals("parked draft", drafts[resolved])
assertEquals("other draft", drafts[unrelated])
assertEquals(listOf(parked, late), attachments.get(resolved))
assertEquals(listOf(unrelatedAttachment), attachments.get(unrelated))
}
@Test
fun pendingAttachmentsAreBoundedAcrossComposerOwners() {
val ownerA = ChatComposerOwner("gateway", "agent-a", "session-a")
val ownerB = ChatComposerOwner("gateway", "agent-b", "session-b")
val store =
ChatComposerAttachmentStore(
maxTotalAttachmentCount = 8,
maxTotalBase64Chars = 8,
maxTotalDecodedBytes = 5,
)
val first = pendingAttachment("first", base64 = "AAAA")
val second = pendingAttachment("second", base64 = "BBBB")
assertEquals(0, store.add(ownerA, listOf(first)))
assertEquals(1, store.add(ownerB, listOf(second)))
assertEquals(listOf(first), store.attachments.value[ownerA])
assertEquals(null, store.attachments.value[ownerB])
}
@Test
fun ownerMigrationDropsAndReportsAttachmentsBeyondTheDestinationLimit() {
val from = ChatComposerOwner("gateway", "main", "main", routingVerified = false)
val to = ChatComposerOwner("gateway", "main", "agent:main:device")
val store = ChatComposerAttachmentStore()
val destination = (1..7).map { pendingAttachment("destination-$it") }
val source = listOf(pendingAttachment("source-1"), pendingAttachment("source-2"))
store.add(to, destination)
store.add(from, source)
assertEquals(1, store.migrate(from, to))
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, store.attachments.value[to]?.size)
assertEquals(null, store.attachments.value[from])
store.remove(to, store.get(to).mapTo(mutableSetOf()) { it.id })
assertEquals(0, store.migrate(from, to))
assertEquals(null, store.attachments.value[to])
}
@Test
fun voiceNoteCompletionMustMatchTheRecordingThatStartedIt() {
val ownerA = ChatComposerOwner("gateway", "agent-a", "session-a")
val ownerB = ChatComposerOwner("gateway", "agent-b", "session-b")
val checkpoint = ChatVoiceNoteCommitCheckpoint()
checkpoint.begin(ownerA, "recording-a", mediaAuthorizationId = "auth-a")
checkpoint.begin(ownerB, "recording-b", mediaAuthorizationId = "auth-b")
assertEquals(null, checkpoint.consume("recording-a"))
assertEquals(ownerB, checkpoint.owner)
assertEquals(ChatComposerMediaLease(ownerB, "auth-b"), checkpoint.consume("recording-b"))
assertEquals(null, checkpoint.owner)
}
@Test
fun imagePickerCheckpointCarriesTheCredentialGenerationThroughRecreation() {
val owner = ChatComposerOwner("gateway", "agent", "session")
val checkpoint = ChatComposerOwnerCheckpoint()
checkpoint.begin(owner, mediaAuthorizationId = "media-auth")
val saverScope = SaverScope { true }
val saved =
with(ChatComposerOwnerCheckpoint.Saver) {
saverScope.save(checkpoint)
}
val restored = requireNotNull(ChatComposerOwnerCheckpoint.Saver.restore(requireNotNull(saved)))
assertEquals(ChatComposerMediaLease(owner, "media-auth"), restored.consume())
}
@Test
fun voiceRecorderSurvivesOnlyCanonicalOwnerMigration() {
val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false)
val canonical = ChatComposerOwner("gateway", "work", "agent:work:device")
val tracker = VoiceNoteRecorderOwnerTracker(provisional)
assertTrue(tracker.moveTo(canonical, canonical.sessionKey))
assertFalse(tracker.moveTo(canonical.copy(sessionKey = "agent:work:other"), canonical.sessionKey))
}
@Test
fun composerOwnerUsesTheSameSessionFallbackAsTheViewModel() {
assertEquals(
ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "alpha", sessionKey = "agent:alpha:main"),
resolveChatComposerOwner(
gatewayStableId = "gateway-a",
gatewayDefaultAgentId = "main",
sessionKey = " ",
mainSessionKey = "agent:alpha:main",
),
)
}
@Test
fun composerOwnerRetainsVerifiedRoutingOnlyForTheSameGateway() {
val retained = GatewayDefaultAgentOwner(gatewayStableId = "gateway-a", agentId = "agent-a")
assertEquals(
ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "main"),
resolveChatComposerOwner(
gatewayStableId = "gateway-a",
gatewayDefaultAgentId = null,
lastVerifiedOwner = retained,
sessionKey = "main",
mainSessionKey = "main",
),
)
assertFalse(
resolveChatComposerOwner(
gatewayStableId = "gateway-b",
gatewayDefaultAgentId = null,
lastVerifiedOwner = retained,
sessionKey = "main",
mainSessionKey = "main",
).routingVerified,
)
}
@Test
fun routingOwnerRejectsABlankGatewayDefaultAgent() {
assertEquals(
null,
resolveChatComposerRoutingOwner(
gatewayStableId = "gateway-a",
gatewayDefaultAgentId = " ",
sessionKey = "main",
mainSessionKey = "main",
),
)
}
@Test
fun stagedShareRejectsAReplacementComposerOwner() {
val share = ChatShareDraft(id = 7, text = "share", imageUris = emptyList(), droppedImageCount = 0)
val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a")
assertFalse(
canCommitStagedChatShare(
stagedId = share.id,
currentHead = share,
ownerSnapshot = owner,
currentOwner = owner.copy(sessionKey = "session-b"),
),
)
}
@Test
@@ -204,6 +860,7 @@ class ChatComposerDraftTest {
pendingRunCount = 0,
hasContent = true,
shareStaging = true,
sendInFlight = false,
),
)
assertTrue(
@@ -212,6 +869,16 @@ class ChatComposerDraftTest {
pendingRunCount = 0,
hasContent = true,
shareStaging = false,
sendInFlight = false,
),
)
assertFalse(
chatComposerSendEnabled(
voiceNoteState = VoiceNoteRecorderState.Idle,
pendingRunCount = 0,
hasContent = true,
shareStaging = false,
sendInFlight = true,
),
)
}
@@ -1,6 +1,8 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.PendingAssistantAutoSend
import ai.openclaw.app.chat.ChatComposerOwner
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -28,24 +30,37 @@ class ChatScreenTest {
@Test
fun resolvesPendingAssistantAutoSendOnlyWhenChatIsReady() {
val owner = ChatComposerOwner(gatewayStableId = "gateway", agentId = "main", sessionKey = "agent:main:device")
val pending = PendingAssistantAutoSend(prompt = " summarize mail ", owner = owner)
assertNull(
resolvePendingAssistantAutoSend(
pendingPrompt = "summarize mail",
pending = pending,
currentOwner = owner,
healthOk = false,
pendingRunCount = 0,
),
)
assertNull(
resolvePendingAssistantAutoSend(
pendingPrompt = "summarize mail",
pending = pending,
currentOwner = owner,
healthOk = true,
pendingRunCount = 1,
),
)
assertEquals(
"summarize mail",
assertNull(
resolvePendingAssistantAutoSend(
pendingPrompt = " summarize mail ",
pending = pending,
currentOwner = owner.copy(sessionKey = "agent:main:other"),
healthOk = true,
pendingRunCount = 0,
),
)
assertEquals(
pending,
resolvePendingAssistantAutoSend(
pending = pending,
currentOwner = owner,
healthOk = true,
pendingRunCount = 0,
),
@@ -5,7 +5,9 @@ import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatOutboxItem
import ai.openclaw.app.chat.ChatOutboxStatus
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.OUTBOX_OWNER_CHANGED_ERROR
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatTimelineTest {
@@ -102,6 +104,7 @@ class ChatTimelineTest {
status = ChatOutboxStatus.Queued,
retryCount = 0,
lastError = null,
ownerAgentId = "main",
)
val consumed =
visible.copy(
@@ -118,6 +121,7 @@ class ChatTimelineTest {
items = listOf(visible, consumed),
sessionKey = "main",
mainSessionKey = "agent:work:main",
ownerAgentId = "main",
messages = listOf(optimisticCopy),
)
@@ -125,6 +129,145 @@ class ChatTimelineTest {
assertEquals(listOf("visible-row"), filtered.map { it.id })
}
@Test
fun outboxRowsStayWithTheirAgentOwner() {
val mainOwner =
ChatOutboxItem(
id = "main-row",
sessionKey = "shared",
text = "main",
thinkingLevel = "off",
createdAtMs = 1,
status = ChatOutboxStatus.Queued,
retryCount = 0,
lastError = null,
ownerAgentId = "main",
)
val otherOwner = mainOwner.copy(id = "other-row", text = "other", ownerAgentId = "other")
val migratedOwnerless = mainOwner.copy(id = "legacy-row", text = "legacy", ownerAgentId = null)
val filtered =
outboxItemsForSession(
items = listOf(mainOwner, otherOwner, migratedOwnerless),
sessionKey = "shared",
mainSessionKey = "agent:main:device",
ownerAgentId = "main",
)
assertEquals(listOf("main-row"), filtered.map { it.id })
}
@Test
fun unreachableRowsRenderOnlyInTheNeutralRecoverySection() {
val ownerless =
ChatOutboxItem(
id = "legacy-row",
sessionKey = "shared",
text = "legacy private text",
thinkingLevel = "off",
createdAtMs = 1,
status = ChatOutboxStatus.Failed,
retryCount = 0,
lastError = "owner unknown",
ownerAgentId = null,
)
assertEquals(listOf(ownerless), outboxItemsForRecovery(listOf(ownerless)))
val timeline =
buildChatTimeline(
messages = emptyList(),
pendingRunCount = 0,
pendingToolCalls = emptyList(),
streamingAssistantText = null,
recoveryOutboxItems = listOf(ownerless),
)
assertEquals(
listOf("outbox-recovery:legacy-row", "outbox-recovery-header"),
timeline.items.map(::chatTimelineItemKey),
)
}
@Test
fun parkedMainAliasRowRemainsReachableForRecovery() {
val captured =
ChatOutboxItem(
id = "captured-main",
sessionKey = "main",
text = "park me",
thinkingLevel = "off",
createdAtMs = 1,
status = ChatOutboxStatus.Failed,
retryCount = 0,
lastError = OUTBOX_OWNER_CHANGED_ERROR,
ownerAgentId = "agent-a",
)
assertEquals(listOf(captured), outboxItemsForRecovery(listOf(captured)))
assertTrue(
outboxItemsForSession(
items = listOf(captured),
sessionKey = "main",
mainSessionKey = "agent:agent-a:device",
ownerAgentId = "agent-a",
).isEmpty(),
)
}
@Test
fun validForeignMainAliasRowStaysHiddenUntilItsCapturedOwnerIsCurrent() {
val captured =
ChatOutboxItem(
id = "captured-main",
sessionKey = "main",
text = "keep private",
thinkingLevel = "off",
createdAtMs = 1,
status = ChatOutboxStatus.Queued,
retryCount = 0,
lastError = null,
ownerAgentId = "agent-a",
)
assertTrue(outboxItemsForRecovery(listOf(captured)).isEmpty())
}
@Test
fun foreignCustomAliasRowStaysHiddenUntilItsCapturedOwnerIsCurrent() {
val captured =
ChatOutboxItem(
id = "captured-custom",
sessionKey = "custom-alias",
text = "park me",
thinkingLevel = "off",
createdAtMs = 1,
status = ChatOutboxStatus.Failed,
retryCount = 0,
lastError = "owner changed",
ownerAgentId = "agent-a",
)
assertTrue(outboxItemsForRecovery(listOf(captured)).isEmpty())
}
@Test
fun agentQualifiedRowMovesToRecoveryWhenItsCapturedOwnerDisagrees() {
val mismatched =
ChatOutboxItem(
id = "mismatched-owner",
sessionKey = "agent:agent-b:device",
text = "park me",
thinkingLevel = "off",
createdAtMs = 1,
status = ChatOutboxStatus.Failed,
retryCount = 0,
lastError = "owner changed",
ownerAgentId = "agent-a",
)
assertEquals(listOf(mismatched), outboxItemsForRecovery(listOf(mismatched)))
}
private fun textMessage(
id: String,
role: String,
@@ -426,9 +426,7 @@ export const sessionDeleteHandlers: GatewayRequestHandlers = {
if (deleted) {
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
...(target.canonicalKey === "global" && requestedAgentId
? { agentId: requestedAgentId }
: {}),
agentId: target.agentId,
reason: "delete",
});
}
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import {
readAcpSessionMeta,
writeAcpSessionMetaForMigration,
@@ -914,6 +914,33 @@ test("sessions.delete emits session_end with deleted reason and no replacement",
expect((context as { agentId?: string } | undefined)?.agentId).toBe("main");
});
test("sessions.delete sessions.changed event always carries the resolved owner", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-side", "hello");
await writeSessionStore({ entries: { "agent:main:side": sessionStoreEntry("sess-side") } });
const broadcastToConnIds = vi.fn();
const deleted = await directSessionReq<{ deleted: boolean }>(
"sessions.delete",
{ key: "agent:main:side", deleteTranscript: true },
{
client: { connect: { scopes: ["operator.admin"] } } as never,
context: {
broadcastToConnIds,
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
},
},
);
expect(deleted).toMatchObject({ ok: true, payload: { deleted: true } });
expect(broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({ sessionKey: "agent:main:side", agentId: "main", reason: "delete" }),
new Set(["conn-1"]),
{ dropIfSlow: true },
);
});
test("sessions.delete does not emit lifecycle events when nothing was deleted", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");