mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
IWhatsskill
Peter Steinberger
Peter Steinberger
parent
6745f9a553
commit
6bd9e5f158
+418
-402
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1103,11 +1103,6 @@
|
||||
"source": "Could not stage an attachment for sending.",
|
||||
"translated": "Impossible de préparer une pièce jointe pour l’envoi."
|
||||
},
|
||||
{
|
||||
"id": "native.android.0b29275eefad9863",
|
||||
"source": "Could not queue message for later delivery.",
|
||||
"translated": "Impossible de mettre le message en file d’attente 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 n’est pas OK ; envoi impossible"
|
||||
},
|
||||
{
|
||||
"id": "native.android.0b29275eefad9863",
|
||||
"source": "Could not queue message for later delivery.",
|
||||
"translated": "Impossible de mettre le message en file d’attente 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 d’utiliser les fonctionnalités d’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">"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,
|
||||
)
|
||||
}
|
||||
|
||||
+45
-14
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-9
@@ -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" })
|
||||
|
||||
+2
-2
@@ -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
+54
@@ -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)
|
||||
|
||||
|
||||
+847
-30
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
+123
-36
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user