mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
feat(android): durable offline chat with attachments and history-proof retirement (#104089)
* feat(android): durable offline chat with attachments and history-proof retirement Every chat send is journaled to the per-gateway Room outbox before any network attempt, so process death always has one durable recovery owner. Rows survive gateway ACKs as 'accepted' and retire only once the turn is proven in canonical chat.history by idempotency key; ambiguous outcomes (lost ACK, kill mid-send, gateway restart before the transcript write) park as delivery-unconfirmed for explicit retry, per the fail-closed model #103273 landed. An unproven accepted head briefly holds only its own session's queue, and retrying a parked head re-orders still-queued successors behind it. Offline sends now accept picked images and voice notes; attachment bytes persist as chunked BLOBs (512 KB chunks, 8 MB per message, 48 MB per gateway) admitted and retired atomically with their row. Pre-hello 'main' rows pin to the canonical session at first dispatch so later default-agent changes cannot retarget captured input; slash commands are connection-gated by a persisted epoch (legacy queued command rows migrate to a never-matching sentinel) and never auto-replay across reconnects. The Room store moves to schema v4 on top of v3's data-parking migration, adding the epoch column and attachment tables while preserving queued rows. The queued->sending claim is an atomic compare-and-set shared by the direct dispatch and the flush loop, the direct path waits for the startup recovery sweep before claiming, and failed reconnect attempts republish outbox rows so queued sends stay visible on offline cold starts. Proof: 1151 Android unit tests and :app:ktlintCheck green (new migration, storage, and controller coverage for admission, restarts, claims, pinning, gating, ordering, and byte round-trips); a live emulator scenario on the pre-integration build queued text+image offline, survived force-stop plus a device reboot, resent exactly once on reconnect (single <rowId>:user turn in the gateway transcript), and drained the outbox after canonical-history confirmation. Closes #104087. * fix(android): rearm outbox recovery when direct-send state persistence fails * fix(android): keep direct dispatch alive across caller cancellation The direct send's network phase now runs in the controller scope, so a cancelled UI scope (leaving the chat screen mid-send) can no longer strand a claimed row in 'sending' with no user action available; the dispatch completes and settles the row exactly once. Direct-path state persistence failures also re-arm the startup recovery sweep, mirroring the flush path's fail-closed handling. * fix(android): hand claim-persistence failures to the flush lane A direct-send claim that fails to persist no longer reads as a lost race: the admitted row is handed to the flush lane so a healthy connection still delivers it, and the flush path's fail-closed handling owns any repeated storage failure instead of the UI reporting success with no active owner. * fix(android): enforce connection gating and fail-closed parking on every send path The direct dispatch now rechecks a slash command's connection epoch after its durable claim, so a reconnect between admission and dispatch parks the command instead of replaying it on the new connection. Parking a stale gated row only counts once the write persists; a storage failure drops health, re-arms recovery, and halts the flush pass instead of spinning or dispatching the stale row. * fix(android): recognize gateway-acknowledged run ids in outbox ownership checks A chat.send ACK can return a run id that differs from the row's idempotency key (the direct path transfers local run ownership to it). Ownership, in-flight, backlog, and timeout-park checks now consider both ids, so reconciliation can no longer park — and Retry can no longer duplicate — a turn that is still running on the gateway. * fix(android): close remaining ack-ownership and persistence-failure gaps in the outbox Flushed sends acked under a divergent run id now transfer local run ownership like the direct path, so live runs cannot time out and surface spurious errors for delivered turns. A session pin that cannot persist stops the dispatch while the row is still safely queued. Reconcile and timeout parking only report changes their writes actually persisted, failing closed on storage errors. * docs(android): record why acknowledged run ids stay in-memory
This commit is contained in:
committed by
GitHub
parent
a6bdc8092b
commit
1c08d96d8b
@@ -5819,7 +5819,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 436,
|
||||
"line": 437,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "You",
|
||||
"surface": "android",
|
||||
@@ -5827,7 +5827,15 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 450,
|
||||
"line": 444,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "📎 ${attachment.fileName}",
|
||||
"surface": "android",
|
||||
"id": "native.android.10d8391dea9f334b"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 460,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "Retry",
|
||||
"surface": "android",
|
||||
@@ -5835,7 +5843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 453,
|
||||
"line": 465,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "Delete",
|
||||
"surface": "android",
|
||||
@@ -5843,7 +5851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 485,
|
||||
"line": 497,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "OpenClaw · Live",
|
||||
"surface": "android",
|
||||
@@ -5851,7 +5859,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 522,
|
||||
"line": 534,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "System",
|
||||
"surface": "android",
|
||||
@@ -5859,7 +5867,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 523,
|
||||
"line": 535,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "OpenClaw",
|
||||
"surface": "android",
|
||||
@@ -5867,7 +5875,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 549,
|
||||
"line": 561,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt",
|
||||
"source": "Unsupported attachment",
|
||||
"surface": "android",
|
||||
@@ -5883,7 +5891,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 517,
|
||||
"line": 518,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "All",
|
||||
"surface": "android",
|
||||
@@ -5891,7 +5899,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 575,
|
||||
"line": 576,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw",
|
||||
"surface": "android",
|
||||
@@ -5899,7 +5907,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 596,
|
||||
"line": 597,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "New chat",
|
||||
"surface": "android",
|
||||
@@ -5907,7 +5915,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 601,
|
||||
"line": 602,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "More new chat options",
|
||||
"surface": "android",
|
||||
@@ -5915,7 +5923,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 616,
|
||||
"line": 617,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Refresh chat",
|
||||
"surface": "android",
|
||||
@@ -5923,7 +5931,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 619,
|
||||
"line": 620,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Chat",
|
||||
"surface": "android",
|
||||
@@ -5931,7 +5939,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 767,
|
||||
"line": 768,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Loading session",
|
||||
"surface": "android",
|
||||
@@ -5939,7 +5947,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 794,
|
||||
"line": 795,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Jump to latest",
|
||||
"surface": "android",
|
||||
@@ -5947,7 +5955,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 820,
|
||||
"line": 821,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Ready when you are",
|
||||
"surface": "android",
|
||||
@@ -5955,7 +5963,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 851,
|
||||
"line": 852,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Fix connection",
|
||||
"surface": "android",
|
||||
@@ -5963,7 +5971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 852,
|
||||
"line": 853,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Copy diagnostics",
|
||||
"surface": "android",
|
||||
@@ -5971,7 +5979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1036,
|
||||
"line": 1037,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Preparing audio…",
|
||||
"surface": "android",
|
||||
@@ -5979,7 +5987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1036,
|
||||
"line": 1037,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Speaking…",
|
||||
"surface": "android",
|
||||
@@ -5987,7 +5995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1057,
|
||||
"line": 1058,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Tools running",
|
||||
"surface": "android",
|
||||
@@ -5995,7 +6003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1059,
|
||||
"line": 1060,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw is working",
|
||||
"surface": "android",
|
||||
@@ -6003,7 +6011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1062,
|
||||
"line": 1063,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "+${toolCalls.size - 4} more",
|
||||
"surface": "android",
|
||||
@@ -6011,7 +6019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1072,
|
||||
"line": 1073,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Thinking",
|
||||
"surface": "android",
|
||||
@@ -6019,7 +6027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1073,
|
||||
"line": 1074,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw is preparing a response.",
|
||||
"surface": "android",
|
||||
@@ -6027,7 +6035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1255,
|
||||
"line": 1253,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Stop",
|
||||
"surface": "android",
|
||||
@@ -6035,7 +6043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1352,
|
||||
"line": 1350,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Default",
|
||||
"surface": "android",
|
||||
@@ -6043,7 +6051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1418,
|
||||
"line": 1416,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Pin model",
|
||||
"surface": "android",
|
||||
@@ -6051,7 +6059,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1418,
|
||||
"line": 1416,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Unpin model",
|
||||
"surface": "android",
|
||||
@@ -6059,7 +6067,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1435,
|
||||
"line": 1433,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "No commands found",
|
||||
"surface": "android",
|
||||
@@ -6067,7 +6075,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1497,
|
||||
"line": 1495,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Gateway offline",
|
||||
"surface": "android",
|
||||
@@ -6075,7 +6083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1543,
|
||||
"line": 1541,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Close thinking level selector",
|
||||
"surface": "android",
|
||||
@@ -6083,7 +6091,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1543,
|
||||
"line": 1541,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Open thinking level selector",
|
||||
"surface": "android",
|
||||
@@ -6091,7 +6099,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1603,
|
||||
"line": 1601,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Attach image",
|
||||
"surface": "android",
|
||||
@@ -6099,7 +6107,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1638,
|
||||
"line": 1636,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Message OpenClaw",
|
||||
"surface": "android",
|
||||
@@ -6107,7 +6115,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1654,
|
||||
"line": 1652,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Open voice",
|
||||
"surface": "android",
|
||||
@@ -6115,7 +6123,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1703,
|
||||
"line": 1701,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Remove attachment",
|
||||
"surface": "android",
|
||||
@@ -6123,7 +6131,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1724,
|
||||
"line": 1722,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Main",
|
||||
"surface": "android",
|
||||
@@ -6131,7 +6139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1732,
|
||||
"line": 1730,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "$emoji $name",
|
||||
"surface": "android",
|
||||
@@ -6139,7 +6147,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1791,
|
||||
"line": 1789,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Send",
|
||||
"surface": "android",
|
||||
@@ -6147,7 +6155,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1822,
|
||||
"line": 1820,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}",
|
||||
"surface": "android",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.chat.ChatCacheDatabase
|
||||
import ai.openclaw.app.chat.RoomChatCommandOutbox
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import android.app.Application
|
||||
@@ -60,7 +61,8 @@ class NodeApp : Application() {
|
||||
database.withTransaction {
|
||||
database.dao().deleteMessages(gatewayId)
|
||||
database.dao().deleteSessions(gatewayId)
|
||||
database.outboxDao().deleteGateway(gatewayId)
|
||||
// The outbox owns command/attachment cascade deletes; nested transactions join this one.
|
||||
RoomChatCommandOutbox(database).clearGateway(gatewayId)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.Insert
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
@@ -20,21 +22,56 @@ internal const val OUTBOX_EXPIRED_ERROR = "expired"
|
||||
/** Delivery is ambiguous after dispatch without an acknowledgement; retry needs explicit intent. */
|
||||
internal const val OUTBOX_DELIVERY_UNCONFIRMED_ERROR = "delivery unconfirmed; retry manually"
|
||||
|
||||
/** 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"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
internal const val OUTBOX_GATED_EPOCH_NEVER = -1L
|
||||
|
||||
/** Chunk size for attachment BLOBs; each chunk row must stay well under Android's CursorWindow cap. */
|
||||
internal const val OUTBOX_ATTACHMENT_CHUNK_BYTES = 512 * 1024
|
||||
|
||||
/** Upper bound of attachment bytes on one queued command (8 images plus a voice note fit). */
|
||||
internal const val OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES = 8L * 1024L * 1024L
|
||||
|
||||
/** Upper bound of queued attachment bytes per gateway so the outbox database stays bounded. */
|
||||
internal const val OUTBOX_MAX_GATEWAY_ATTACHMENT_BYTES = 48L * 1024L * 1024L
|
||||
|
||||
enum class ChatOutboxStatus(
|
||||
internal val dbValue: String,
|
||||
) {
|
||||
Queued("queued"),
|
||||
Sending("sending"),
|
||||
|
||||
/**
|
||||
* The gateway acknowledged the send, but only canonical chat.history proves the user turn was
|
||||
* durably persisted (the started ACK is emitted before the transcript write). Accepted rows are
|
||||
* retired exclusively by history confirmation, or parked as failed when confirmation never lands.
|
||||
*/
|
||||
Accepted("accepted"),
|
||||
Failed("failed"),
|
||||
;
|
||||
|
||||
internal companion object {
|
||||
// Destructive migration keeps the schema in lockstep, so unknown values should not occur;
|
||||
// park anything unexpected as Failed so it stays visible instead of silently sending.
|
||||
// Schema bumps migrate explicitly, so unknown values should not occur; park anything
|
||||
// unexpected as Failed so it stays visible instead of silently sending.
|
||||
fun fromDb(value: String): ChatOutboxStatus = entries.firstOrNull { it.dbValue == value } ?: Failed
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata for one durable attachment; bytes live in chunked BLOB rows keyed by [id]. */
|
||||
data class ChatOutboxAttachment(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val mimeType: String,
|
||||
val fileName: String,
|
||||
val durationMs: Long?,
|
||||
val byteLength: Long,
|
||||
)
|
||||
|
||||
/** One durable queued chat command; [id] doubles as the chat.send idempotency key. */
|
||||
data class ChatOutboxItem(
|
||||
val id: String,
|
||||
@@ -47,6 +84,25 @@ data class ChatOutboxItem(
|
||||
val status: ChatOutboxStatus,
|
||||
val retryCount: Int,
|
||||
val lastError: String?,
|
||||
// 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,
|
||||
val attachments: List<ChatOutboxAttachment> = emptyList(),
|
||||
)
|
||||
|
||||
/** Attachment bytes captured at enqueue time; stored as binary chunks, never base64 at rest. */
|
||||
class OutboxAttachmentPayload(
|
||||
val type: String,
|
||||
val mimeType: String,
|
||||
val fileName: String,
|
||||
val durationMs: Long?,
|
||||
val bytes: ByteArray,
|
||||
)
|
||||
|
||||
/** One attachment re-assembled for a flush dispatch or a restored optimistic bubble. */
|
||||
class LoadedOutboxAttachment(
|
||||
val attachment: ChatOutboxAttachment,
|
||||
val bytes: ByteArray,
|
||||
)
|
||||
|
||||
sealed interface ChatOutboxEnqueueResult {
|
||||
@@ -56,20 +112,28 @@ sealed interface ChatOutboxEnqueueResult {
|
||||
|
||||
data object QueueFull : ChatOutboxEnqueueResult
|
||||
|
||||
/** One command's attachments exceed [OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES]; deleting rows cannot help. */
|
||||
data object AttachmentsTooLarge : ChatOutboxEnqueueResult
|
||||
|
||||
/** The per-gateway attachment byte budget is exhausted; deleting queued rows frees space. */
|
||||
data object StorageFull : ChatOutboxEnqueueResult
|
||||
|
||||
/** No gateway identity is available (nothing paired/configured), so nothing can be queued. */
|
||||
data object Unavailable : ChatOutboxEnqueueResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable offline outbox for text chat commands.
|
||||
* Durable outbox for chat sends. Every send is journaled here before any network attempt so
|
||||
* process death always has exactly one recovery owner; rows survive until canonical chat.history
|
||||
* proves the user turn persisted, they terminally fail, expire, or the user deletes them.
|
||||
*
|
||||
* Unlike the disposable transcript cache, queued rows are user input that must survive process
|
||||
* restarts until they are acked by the gateway, expired, or explicitly deleted. Like the cache,
|
||||
* callers bind every gateway-scoped operation to an explicit [ChatCacheScope] gateway id captured
|
||||
* before their suspend point, so a connection switch cannot re-scope rows mid-operation.
|
||||
* restarts and schema migrations. Like the cache, callers bind every gateway-scoped operation to
|
||||
* an explicit [ChatCacheScope] gateway id captured before their suspend point, so a connection
|
||||
* switch cannot re-scope rows mid-operation.
|
||||
*/
|
||||
interface ChatCommandOutbox {
|
||||
/** All rows for [gatewayId], strictly createdAt-ordered. */
|
||||
/** All rows for [gatewayId] with attachment metadata, strictly createdAt-ordered. */
|
||||
suspend fun load(gatewayId: String): List<ChatOutboxItem>
|
||||
|
||||
suspend fun enqueue(
|
||||
@@ -78,8 +142,13 @@ interface ChatCommandOutbox {
|
||||
text: String,
|
||||
thinkingLevel: String,
|
||||
nowMs: Long,
|
||||
attachments: List<OutboxAttachmentPayload> = emptyList(),
|
||||
gatedEpoch: Long? = null,
|
||||
): ChatOutboxEnqueueResult
|
||||
|
||||
/** Re-assembles the attachment bytes for one command, in stable position order. */
|
||||
suspend fun loadAttachments(id: String): List<LoadedOutboxAttachment>
|
||||
|
||||
/** Returns the number of rows updated (0 when the row no longer exists), so callers can claim. */
|
||||
suspend fun updateStatus(
|
||||
id: String,
|
||||
@@ -88,19 +157,47 @@ interface ChatCommandOutbox {
|
||||
lastError: String?,
|
||||
): Int
|
||||
|
||||
/**
|
||||
* Atomically claims a queued row for one dispatch (queued -> sending). Returns 0 when the row
|
||||
* vanished or another dispatcher already claimed it, so the direct-send path and the flush
|
||||
* loop can never both send the same row.
|
||||
*/
|
||||
suspend fun claimForSending(
|
||||
id: String,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
): Int
|
||||
|
||||
/**
|
||||
* Pins a row enqueued under the pre-hello "main" alias to the canonical session key it first
|
||||
* resolves to. Replay after that must never re-resolve, so a later default-agent change
|
||||
* cannot redirect already-captured input.
|
||||
*/
|
||||
suspend fun pinSessionKey(
|
||||
id: String,
|
||||
sessionKey: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-driven retry of a failed row owned by [gatewayId]: back to 'queued' with reset attempts
|
||||
* and a fresh createdAt, so an expired row is not immediately re-expired by the flush sweep.
|
||||
* Returns the number of rows transitioned; keeps the row id as the gateway idempotency key.
|
||||
* Gated rows are re-stamped with the caller's current connection epoch, and queued successors
|
||||
* in the same session shift behind the retried row in their original order, so retrying an
|
||||
* ambiguous head can never make younger turns of the conversation overtake it.
|
||||
*/
|
||||
suspend fun requeueForRetry(
|
||||
gatewayId: String,
|
||||
id: String,
|
||||
nowMs: Long,
|
||||
gatedEpoch: Long?,
|
||||
): Int
|
||||
|
||||
suspend fun delete(id: String)
|
||||
|
||||
/** Retires rows proven delivered by canonical history; returns how many rows were removed. */
|
||||
suspend fun confirmDelivered(ids: Set<String>): Int
|
||||
|
||||
/** Drops queued commands for a deleted session so they cannot send into a dead session. */
|
||||
suspend fun deleteForSession(
|
||||
gatewayId: String,
|
||||
@@ -113,7 +210,11 @@ interface ChatCommandOutbox {
|
||||
/** Crash safety: rows stuck in 'sending' after a killed process become visible failed rows. */
|
||||
suspend fun failSendingAfterRestart()
|
||||
|
||||
/** Expires queued rows older than [OUTBOX_EXPIRY_MS] to 'failed' instead of sending stale commands. */
|
||||
/**
|
||||
* Expires stale rows to 'failed' instead of sending stale commands: queued rows older than
|
||||
* [OUTBOX_EXPIRY_MS] expire, and accepted rows never confirmed within the same window park
|
||||
* as delivery-unconfirmed so they stay visible for manual review.
|
||||
*/
|
||||
suspend fun expireStale(
|
||||
gatewayId: String,
|
||||
nowMs: Long,
|
||||
@@ -131,6 +232,32 @@ internal data class OutboxCommandEntity(
|
||||
val status: String,
|
||||
val retryCount: Int,
|
||||
val lastError: String?,
|
||||
val gatedEpoch: Long?,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "outbox_attachments",
|
||||
indices = [Index("commandId")],
|
||||
)
|
||||
internal data class OutboxAttachmentEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val commandId: String,
|
||||
val position: Int,
|
||||
val type: String,
|
||||
val mimeType: String,
|
||||
val fileName: String,
|
||||
val durationMs: Long?,
|
||||
val byteLength: Long,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "outbox_attachment_chunks",
|
||||
primaryKeys = ["attachmentId", "chunkIndex"],
|
||||
)
|
||||
internal class OutboxAttachmentChunkEntity(
|
||||
val attachmentId: String,
|
||||
val chunkIndex: Int,
|
||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) val bytes: ByteArray,
|
||||
)
|
||||
|
||||
@Dao
|
||||
@@ -139,6 +266,15 @@ 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")
|
||||
suspend fun commandIdsForSession(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
): List<String>
|
||||
|
||||
@Query("SELECT id FROM outbox_commands WHERE gatewayId = :gatewayId")
|
||||
suspend fun commandIdsForGateway(gatewayId: String): List<String>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM outbox_commands WHERE gatewayId = :gatewayId")
|
||||
suspend fun count(gatewayId: String): Int
|
||||
|
||||
@@ -156,6 +292,30 @@ internal interface ChatOutboxDao {
|
||||
lastError: String?,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"UPDATE outbox_commands SET status = :toStatus, retryCount = :retryCount, lastError = :lastError " +
|
||||
"WHERE id = :id AND status = :fromStatus",
|
||||
)
|
||||
suspend fun claimStatus(
|
||||
id: String,
|
||||
fromStatus: String,
|
||||
toStatus: String,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
): Int
|
||||
|
||||
@Query("UPDATE outbox_commands SET sessionKey = :sessionKey WHERE id = :id")
|
||||
suspend fun updateSessionKey(
|
||||
id: String,
|
||||
sessionKey: String,
|
||||
)
|
||||
|
||||
@Query("UPDATE outbox_commands SET createdAtMs = :createdAtMs WHERE id = :id")
|
||||
suspend fun updateCreatedAt(
|
||||
id: String,
|
||||
createdAtMs: Long,
|
||||
)
|
||||
|
||||
@Query("UPDATE outbox_commands SET status = :failedStatus, lastError = :error WHERE status = :sendingStatus")
|
||||
suspend fun failAllSending(
|
||||
sendingStatus: String,
|
||||
@@ -164,8 +324,8 @@ internal interface ChatOutboxDao {
|
||||
)
|
||||
|
||||
@Query(
|
||||
"UPDATE outbox_commands SET status = :queuedStatus, retryCount = 0, lastError = NULL, createdAtMs = :createdAtMs " +
|
||||
"WHERE id = :id AND gatewayId = :gatewayId AND status = :failedStatus",
|
||||
"UPDATE outbox_commands SET status = :queuedStatus, retryCount = 0, lastError = NULL, createdAtMs = :createdAtMs, " +
|
||||
"gatedEpoch = :gatedEpoch WHERE id = :id AND gatewayId = :gatewayId AND status = :failedStatus",
|
||||
)
|
||||
suspend fun requeueForRetry(
|
||||
id: String,
|
||||
@@ -173,54 +333,71 @@ internal interface ChatOutboxDao {
|
||||
createdAtMs: Long,
|
||||
queuedStatus: String,
|
||||
failedStatus: String,
|
||||
gatedEpoch: Long?,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"UPDATE outbox_commands SET status = :failedStatus, lastError = :error " +
|
||||
"WHERE gatewayId = :gatewayId AND status = :queuedStatus AND createdAtMs <= :cutoffMs",
|
||||
"WHERE gatewayId = :gatewayId AND status = :fromStatus AND createdAtMs <= :cutoffMs",
|
||||
)
|
||||
suspend fun expireQueuedAtOrBefore(
|
||||
suspend fun expireStatusAtOrBefore(
|
||||
gatewayId: String,
|
||||
cutoffMs: Long,
|
||||
queuedStatus: String,
|
||||
fromStatus: String,
|
||||
failedStatus: String,
|
||||
error: String,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM outbox_commands WHERE id = :id")
|
||||
suspend fun delete(id: String)
|
||||
suspend fun delete(id: String): Int
|
||||
|
||||
@Query("DELETE FROM outbox_commands WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey")
|
||||
suspend fun deleteForSession(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
@Query("SELECT * FROM outbox_attachments WHERE commandId IN (:commandIds) ORDER BY position ASC")
|
||||
suspend fun attachmentsForCommands(commandIds: List<String>): List<OutboxAttachmentEntity>
|
||||
|
||||
@Query("SELECT * FROM outbox_attachments WHERE commandId = :commandId ORDER BY position ASC")
|
||||
suspend fun attachmentsForCommand(commandId: String): List<OutboxAttachmentEntity>
|
||||
|
||||
@Query("SELECT bytes FROM outbox_attachment_chunks WHERE attachmentId = :attachmentId ORDER BY chunkIndex ASC")
|
||||
suspend fun chunksForAttachment(attachmentId: String): List<ByteArray>
|
||||
|
||||
@Insert
|
||||
suspend fun insertAttachment(row: OutboxAttachmentEntity)
|
||||
|
||||
@Insert
|
||||
suspend fun insertChunk(row: OutboxAttachmentChunkEntity)
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(byteLength), 0) FROM outbox_attachments WHERE commandId IN " +
|
||||
"(SELECT id FROM outbox_commands WHERE gatewayId = :gatewayId)",
|
||||
)
|
||||
suspend fun attachmentBytesForGateway(gatewayId: String): Long
|
||||
|
||||
@Query("DELETE FROM outbox_commands WHERE gatewayId = :gatewayId")
|
||||
suspend fun deleteGateway(gatewayId: String)
|
||||
@Query(
|
||||
"DELETE FROM outbox_attachment_chunks WHERE attachmentId IN " +
|
||||
"(SELECT id FROM outbox_attachments WHERE commandId = :commandId)",
|
||||
)
|
||||
suspend fun deleteChunksForCommand(commandId: String)
|
||||
|
||||
@Query("DELETE FROM outbox_attachments WHERE commandId = :commandId")
|
||||
suspend fun deleteAttachmentsForCommand(commandId: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-backed [ChatCommandOutbox] sharing the chat cache database. Callers pass the gateway id
|
||||
* captured before their suspend point; a blank identity disables both reads and writes.
|
||||
* Command rows and their attachment bytes are admitted and retired in single transactions, so
|
||||
* a crash can never orphan bytes or strand a row without its attachments.
|
||||
*/
|
||||
class RoomChatCommandOutbox internal constructor(
|
||||
private val database: ChatCacheDatabase,
|
||||
) : ChatCommandOutbox {
|
||||
override suspend fun load(gatewayId: String): List<ChatOutboxItem> {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
|
||||
return database.outboxDao().commands(gateway).map { row ->
|
||||
ChatOutboxItem(
|
||||
id = row.id,
|
||||
sessionKey = row.sessionKey,
|
||||
text = row.text,
|
||||
thinkingLevel = row.thinkingLevel,
|
||||
createdAtMs = row.createdAtMs,
|
||||
status = ChatOutboxStatus.fromDb(row.status),
|
||||
retryCount = row.retryCount,
|
||||
lastError = row.lastError,
|
||||
)
|
||||
}
|
||||
val dao = database.outboxDao()
|
||||
val rows = dao.commands(gateway)
|
||||
if (rows.isEmpty()) return emptyList()
|
||||
val attachmentsByCommand = dao.attachmentsForCommands(rows.map { it.id }).groupBy { it.commandId }
|
||||
return rows.map { row -> row.toItem(attachmentsByCommand[row.id].orEmpty()) }
|
||||
}
|
||||
|
||||
override suspend fun enqueue(
|
||||
@@ -229,48 +406,92 @@ class RoomChatCommandOutbox internal constructor(
|
||||
text: String,
|
||||
thinkingLevel: String,
|
||||
nowMs: Long,
|
||||
attachments: List<OutboxAttachmentPayload>,
|
||||
gatedEpoch: Long?,
|
||||
): ChatOutboxEnqueueResult {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return ChatOutboxEnqueueResult.Unavailable
|
||||
val key = sessionKey.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
|
||||
}
|
||||
val dao = database.outboxDao()
|
||||
// The bound counts every row (failed included) so total storage stays capped; failed rows
|
||||
// are user-visible and deletable, so a full queue is always recoverable from the UI.
|
||||
val row =
|
||||
database.withTransaction {
|
||||
if (dao.count(gateway) >= OUTBOX_MAX_QUEUED) {
|
||||
null
|
||||
} else {
|
||||
// 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 entity =
|
||||
OutboxCommandEntity(
|
||||
// Admission is one transaction: capacity checks plus the command, attachment, and chunk
|
||||
// rows commit atomically, so durable admission is all-or-nothing across a crash. The row
|
||||
// bound counts every row (failed included) so total storage stays capped; failed rows are
|
||||
// user-visible and deletable, so a full queue is always recoverable from the UI.
|
||||
return database.withTransaction {
|
||||
if (dao.count(gateway) >= OUTBOX_MAX_QUEUED) {
|
||||
return@withTransaction ChatOutboxEnqueueResult.QueueFull
|
||||
}
|
||||
if (attachmentBytes > 0 &&
|
||||
dao.attachmentBytesForGateway(gateway) + attachmentBytes > OUTBOX_MAX_GATEWAY_ATTACHMENT_BYTES
|
||||
) {
|
||||
return@withTransaction ChatOutboxEnqueueResult.StorageFull
|
||||
}
|
||||
// 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 entity =
|
||||
OutboxCommandEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
gatewayId = gateway,
|
||||
sessionKey = key,
|
||||
text = text,
|
||||
thinkingLevel = thinkingLevel,
|
||||
createdAtMs = createdAt,
|
||||
status = ChatOutboxStatus.Queued.dbValue,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
gatedEpoch = gatedEpoch,
|
||||
)
|
||||
dao.insert(entity)
|
||||
val storedAttachments =
|
||||
attachments.mapIndexed { position, payload ->
|
||||
val attachmentEntity =
|
||||
OutboxAttachmentEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
gatewayId = gateway,
|
||||
sessionKey = key,
|
||||
text = text,
|
||||
thinkingLevel = thinkingLevel,
|
||||
createdAtMs = createdAt,
|
||||
status = ChatOutboxStatus.Queued.dbValue,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
commandId = entity.id,
|
||||
position = position,
|
||||
type = payload.type,
|
||||
mimeType = payload.mimeType,
|
||||
fileName = payload.fileName,
|
||||
durationMs = payload.durationMs,
|
||||
byteLength = payload.bytes.size.toLong(),
|
||||
)
|
||||
dao.insert(entity)
|
||||
entity
|
||||
dao.insertAttachment(attachmentEntity)
|
||||
var chunkIndex = 0
|
||||
var offset = 0
|
||||
while (offset < payload.bytes.size) {
|
||||
val end = minOf(offset + OUTBOX_ATTACHMENT_CHUNK_BYTES, payload.bytes.size)
|
||||
dao.insertChunk(
|
||||
OutboxAttachmentChunkEntity(
|
||||
attachmentId = attachmentEntity.id,
|
||||
chunkIndex = chunkIndex,
|
||||
bytes = payload.bytes.copyOfRange(offset, end),
|
||||
),
|
||||
)
|
||||
chunkIndex += 1
|
||||
offset = end
|
||||
}
|
||||
attachmentEntity
|
||||
}
|
||||
} ?: return ChatOutboxEnqueueResult.QueueFull
|
||||
return ChatOutboxEnqueueResult.Queued(
|
||||
ChatOutboxItem(
|
||||
id = row.id,
|
||||
sessionKey = row.sessionKey,
|
||||
text = row.text,
|
||||
thinkingLevel = row.thinkingLevel,
|
||||
createdAtMs = row.createdAtMs,
|
||||
status = ChatOutboxStatus.Queued,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
),
|
||||
)
|
||||
ChatOutboxEnqueueResult.Queued(entity.toItem(storedAttachments))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadAttachments(id: String): List<LoadedOutboxAttachment> {
|
||||
val dao = database.outboxDao()
|
||||
return dao.attachmentsForCommand(id).map { row ->
|
||||
val chunks = dao.chunksForAttachment(row.id)
|
||||
val bytes = ByteArray(chunks.sumOf { it.size })
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
chunk.copyInto(bytes, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
LoadedOutboxAttachment(attachment = row.toAttachment(), bytes = bytes)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateStatus(
|
||||
@@ -280,28 +501,84 @@ class RoomChatCommandOutbox internal constructor(
|
||||
lastError: String?,
|
||||
): Int = database.outboxDao().updateStatus(id = id, status = status.dbValue, retryCount = retryCount, lastError = lastError)
|
||||
|
||||
override suspend fun claimForSending(
|
||||
id: String,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
): Int =
|
||||
database.outboxDao().claimStatus(
|
||||
id = id,
|
||||
fromStatus = ChatOutboxStatus.Queued.dbValue,
|
||||
toStatus = ChatOutboxStatus.Sending.dbValue,
|
||||
retryCount = retryCount,
|
||||
lastError = lastError,
|
||||
)
|
||||
|
||||
override suspend fun pinSessionKey(
|
||||
id: String,
|
||||
sessionKey: String,
|
||||
) {
|
||||
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
|
||||
database.outboxDao().updateSessionKey(id = id, sessionKey = key)
|
||||
}
|
||||
|
||||
override suspend fun requeueForRetry(
|
||||
gatewayId: String,
|
||||
id: String,
|
||||
nowMs: Long,
|
||||
gatedEpoch: Long?,
|
||||
): Int {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return 0
|
||||
val dao = database.outboxDao()
|
||||
return database.withTransaction {
|
||||
// Same monotonic clamp as enqueue: a retried row re-joins the end of the FIFO queue.
|
||||
val createdAt = maxOf(nowMs, (dao.maxCreatedAt(gateway) ?: Long.MIN_VALUE) + 1)
|
||||
dao.requeueForRetry(
|
||||
id = id,
|
||||
gatewayId = gateway,
|
||||
createdAtMs = createdAt,
|
||||
queuedStatus = ChatOutboxStatus.Queued.dbValue,
|
||||
failedStatus = ChatOutboxStatus.Failed.dbValue,
|
||||
)
|
||||
val rows = dao.commands(gateway)
|
||||
val target = rows.firstOrNull { it.id == id } ?: return@withTransaction 0
|
||||
// Same monotonic clamp as enqueue: the fresh createdAt keeps the expiry sweep from
|
||||
// immediately re-failing a retried stale row.
|
||||
var createdAt = maxOf(nowMs, (dao.maxCreatedAt(gateway) ?: Long.MIN_VALUE) + 1)
|
||||
val transitioned =
|
||||
dao.requeueForRetry(
|
||||
id = id,
|
||||
gatewayId = gateway,
|
||||
createdAtMs = createdAt,
|
||||
queuedStatus = ChatOutboxStatus.Queued.dbValue,
|
||||
failedStatus = ChatOutboxStatus.Failed.dbValue,
|
||||
gatedEpoch = gatedEpoch,
|
||||
)
|
||||
if (transitioned > 0) {
|
||||
// Queued same-session successors follow the retried row in their original order, so
|
||||
// retrying an ambiguous head cannot let younger conversation turns overtake it.
|
||||
for (successor in rows) {
|
||||
val follows =
|
||||
successor.id != id &&
|
||||
successor.sessionKey == target.sessionKey &&
|
||||
successor.createdAtMs > target.createdAtMs &&
|
||||
ChatOutboxStatus.fromDb(successor.status) == ChatOutboxStatus.Queued
|
||||
if (follows) {
|
||||
createdAt += 1
|
||||
dao.updateCreatedAt(id = successor.id, createdAtMs = createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
transitioned
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String) {
|
||||
database.outboxDao().delete(id)
|
||||
database.withTransaction {
|
||||
deleteCommandRowLocked(id)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun confirmDelivered(ids: Set<String>): Int {
|
||||
if (ids.isEmpty()) return 0
|
||||
return database.withTransaction {
|
||||
var removed = 0
|
||||
for (id in ids) {
|
||||
removed += deleteCommandRowLocked(id)
|
||||
}
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteForSession(
|
||||
@@ -310,12 +587,22 @@ class RoomChatCommandOutbox internal constructor(
|
||||
) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
|
||||
database.outboxDao().deleteForSession(gateway, key)
|
||||
val dao = database.outboxDao()
|
||||
database.withTransaction {
|
||||
for (id in dao.commandIdsForSession(gateway, key)) {
|
||||
deleteCommandRowLocked(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearGateway(gatewayId: String) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
database.outboxDao().deleteGateway(gateway)
|
||||
val dao = database.outboxDao()
|
||||
database.withTransaction {
|
||||
for (id in dao.commandIdsForGateway(gateway)) {
|
||||
deleteCommandRowLocked(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun failSendingAfterRestart() {
|
||||
@@ -333,14 +620,60 @@ class RoomChatCommandOutbox internal constructor(
|
||||
nowMs: Long,
|
||||
) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
database.outboxDao().expireQueuedAtOrBefore(
|
||||
gatewayId = gateway,
|
||||
cutoffMs = nowMs - OUTBOX_EXPIRY_MS,
|
||||
queuedStatus = ChatOutboxStatus.Queued.dbValue,
|
||||
failedStatus = ChatOutboxStatus.Failed.dbValue,
|
||||
error = OUTBOX_EXPIRED_ERROR,
|
||||
)
|
||||
val dao = database.outboxDao()
|
||||
val cutoff = nowMs - OUTBOX_EXPIRY_MS
|
||||
database.withTransaction {
|
||||
dao.expireStatusAtOrBefore(
|
||||
gatewayId = gateway,
|
||||
cutoffMs = cutoff,
|
||||
fromStatus = ChatOutboxStatus.Queued.dbValue,
|
||||
failedStatus = ChatOutboxStatus.Failed.dbValue,
|
||||
error = OUTBOX_EXPIRED_ERROR,
|
||||
)
|
||||
// Accepted rows the gateway never confirmed within the window stay visible as failed
|
||||
// instead of silently occupying the queue forever.
|
||||
dao.expireStatusAtOrBefore(
|
||||
gatewayId = gateway,
|
||||
cutoffMs = cutoff,
|
||||
fromStatus = ChatOutboxStatus.Accepted.dbValue,
|
||||
failedStatus = ChatOutboxStatus.Failed.dbValue,
|
||||
error = OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment chunk and metadata rows must die with their command row in the same
|
||||
// transaction; callers wrap this in database.withTransaction.
|
||||
private suspend fun deleteCommandRowLocked(id: String): Int {
|
||||
val dao = database.outboxDao()
|
||||
dao.deleteChunksForCommand(id)
|
||||
dao.deleteAttachmentsForCommand(id)
|
||||
return dao.delete(id)
|
||||
}
|
||||
|
||||
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun OutboxCommandEntity.toItem(attachments: List<OutboxAttachmentEntity>): ChatOutboxItem =
|
||||
ChatOutboxItem(
|
||||
id = id,
|
||||
sessionKey = sessionKey,
|
||||
text = text,
|
||||
thinkingLevel = thinkingLevel,
|
||||
createdAtMs = createdAtMs,
|
||||
status = ChatOutboxStatus.fromDb(status),
|
||||
retryCount = retryCount,
|
||||
lastError = lastError,
|
||||
gatedEpoch = gatedEpoch,
|
||||
attachments = attachments.map { it.toAttachment() },
|
||||
)
|
||||
|
||||
private fun OutboxAttachmentEntity.toAttachment(): ChatOutboxAttachment =
|
||||
ChatOutboxAttachment(
|
||||
id = id,
|
||||
type = type,
|
||||
mimeType = mimeType,
|
||||
fileName = fileName,
|
||||
durationMs = durationMs,
|
||||
byteLength = byteLength,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,8 +158,14 @@ internal interface ChatCacheDao {
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [CachedSessionEntity::class, CachedMessageEntity::class, OutboxCommandEntity::class],
|
||||
version = 3,
|
||||
entities = [
|
||||
CachedSessionEntity::class,
|
||||
CachedMessageEntity::class,
|
||||
OutboxCommandEntity::class,
|
||||
OutboxAttachmentEntity::class,
|
||||
OutboxAttachmentChunkEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
exportSchema = false,
|
||||
)
|
||||
internal abstract class ChatCacheDatabase : RoomDatabase() {
|
||||
@@ -186,13 +192,36 @@ internal abstract class ChatCacheDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_3_4 =
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER")
|
||||
// Legacy queued command-shaped rows predate connection epochs; the sentinel makes
|
||||
// them park for explicit retry instead of silently replaying on the next reconnect.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'",
|
||||
arrayOf<Any?>(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue),
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " +
|
||||
"`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " +
|
||||
"`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " +
|
||||
"`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun open(
|
||||
context: Context,
|
||||
name: String = CHAT_TRANSCRIPT_CACHE_DB_NAME,
|
||||
): ChatCacheDatabase =
|
||||
Room
|
||||
.databaseBuilder(context, ChatCacheDatabase::class.java, name)
|
||||
.addMigrations(MIGRATION_2_3)
|
||||
.addMigrations(MIGRATION_2_3, MIGRATION_3_4)
|
||||
// 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)
|
||||
|
||||
@@ -424,6 +424,7 @@ fun ChatOutboxBubble(
|
||||
when (item.status) {
|
||||
ChatOutboxStatus.Queued -> "Queued — sends when reconnected"
|
||||
ChatOutboxStatus.Sending -> "Sending…"
|
||||
ChatOutboxStatus.Accepted -> "Sent — confirming delivery…"
|
||||
ChatOutboxStatus.Failed ->
|
||||
item.lastError
|
||||
?.trim()
|
||||
@@ -435,7 +436,16 @@ fun ChatOutboxBubble(
|
||||
style = bubbleStyle("user").copy(borderColor = statusColor.copy(alpha = 0.6f)),
|
||||
roleLabel = "You",
|
||||
) {
|
||||
ChatMarkdown(text = item.text, textColor = mobileText)
|
||||
if (item.text.isNotBlank()) {
|
||||
ChatMarkdown(text = item.text, textColor = mobileText)
|
||||
}
|
||||
item.attachments.forEach { attachment ->
|
||||
Text(
|
||||
text = "📎 ${attachment.fileName}",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -449,7 +459,9 @@ fun ChatOutboxBubble(
|
||||
if (failed) {
|
||||
ChatOutboxAction(label = "Retry", color = mobileAccent, onClick = onRetry)
|
||||
}
|
||||
if (item.status != ChatOutboxStatus.Sending) {
|
||||
// Sending rows are mid-dispatch and accepted rows may already be delivered; both stay
|
||||
// action-free until reconciliation resolves them, so a delete can never race a send.
|
||||
if (item.status == ChatOutboxStatus.Queued || failed) {
|
||||
ChatOutboxAction(label = "Delete", color = mobileTextSecondary, onClick = onDelete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,7 @@ fun ChatScreen(
|
||||
items = outboxItems,
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
messages = messages,
|
||||
),
|
||||
onRetryOutbox = viewModel::retryChatOutboxCommand,
|
||||
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
|
||||
@@ -1143,15 +1144,13 @@ private fun ChatComposer(
|
||||
if (!thinkingSupported) thinkingSelectorExpanded = false
|
||||
}
|
||||
|
||||
// Offline sends queue durably too (text, images, and voice notes), so the gate is identical
|
||||
// to the connected one; admission errors keep the draft when the durable queue refuses it.
|
||||
val sendEnabled =
|
||||
voiceNoteState !is VoiceNoteRecorderState.Recording &&
|
||||
voiceNoteState !is VoiceNoteRecorderState.Preparing &&
|
||||
pendingRunCount == 0 &&
|
||||
if (healthOk) {
|
||||
value.trim().isNotEmpty() || attachments.isNotEmpty()
|
||||
} else {
|
||||
value.trim().isNotEmpty() && attachments.isEmpty()
|
||||
}
|
||||
(value.trim().isNotEmpty() || attachments.isNotEmpty())
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().imePadding(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (attachments.isNotEmpty()) {
|
||||
@@ -1221,7 +1220,6 @@ private fun ChatComposer(
|
||||
)
|
||||
}
|
||||
SendButton(
|
||||
// Offline, only text sends are enabled: they queue durably (text-only v1).
|
||||
enabled = sendEnabled,
|
||||
onClick = onSend,
|
||||
)
|
||||
|
||||
@@ -89,18 +89,25 @@ internal fun buildChatTimeline(
|
||||
|
||||
/**
|
||||
* Outbox rows for the visible session. Rows enqueued under the "main" alias still belong to the
|
||||
* canonical main session once the gateway hello rewrites the current key.
|
||||
* 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.
|
||||
*/
|
||||
internal fun outboxItemsForSession(
|
||||
items: List<ChatOutboxItem>,
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
messages: List<ChatMessage> = emptyList(),
|
||||
): List<ChatOutboxItem> {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = sessionKey.trim().let { if (it == "main") mainKey else it }
|
||||
val visibleUserKeys =
|
||||
messages
|
||||
.mapNotNull { message -> message.idempotencyKey?.trim()?.takeIf { it.isNotEmpty() } }
|
||||
.toSet()
|
||||
return items.filter { item ->
|
||||
val itemKey = item.sessionKey.let { if (it == "main") mainKey else it }
|
||||
itemKey == current
|
||||
itemKey == current && "${item.id}:user" !in visibleUserKeys
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-4
@@ -4,6 +4,7 @@ import android.database.sqlite.SQLiteDatabase
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -23,15 +24,17 @@ class ChatCacheDatabaseMigrationTest {
|
||||
|
||||
val database = ChatCacheDatabase.open(context, databaseName)
|
||||
try {
|
||||
// Opening through Room executes the production migration and validates the complete v3
|
||||
// schema, including columns, nullability, primary keys, defaults, and indices.
|
||||
assertEquals(3, database.openHelper.writableDatabase.version)
|
||||
// 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)
|
||||
|
||||
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)
|
||||
assertNull(pristine.gatedEpoch)
|
||||
assertTrue(pristine.attachments.isEmpty())
|
||||
|
||||
for (id in listOf("legacy-queued-error", "interrupted-send")) {
|
||||
val migrated = rows.getValue(id)
|
||||
@@ -41,6 +44,11 @@ class ChatCacheDatabaseMigrationTest {
|
||||
val alreadyFailed = rows.getValue("already-failed")
|
||||
assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status)
|
||||
assertEquals("original failure", alreadyFailed.lastError)
|
||||
// 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(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch)
|
||||
assertEquals(
|
||||
"Cached session",
|
||||
database
|
||||
@@ -55,6 +63,49 @@ class ChatCacheDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upgradedStoreSupportsAttachmentsAndSurvivesReopen() =
|
||||
runTest {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val databaseName = "chat-cache-migration-${UUID.randomUUID()}.db"
|
||||
val databaseFile = context.getDatabasePath(databaseName)
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
createV2Fixture(databaseFile.path)
|
||||
|
||||
// Spans multiple chunks to prove chunked reassembly is byte-exact after a real upgrade.
|
||||
val bytes = ByteArray(OUTBOX_ATTACHMENT_CHUNK_BYTES + 77) { (it % 127).toByte() }
|
||||
val queuedId: String
|
||||
val first = ChatCacheDatabase.open(context, databaseName)
|
||||
try {
|
||||
val queued =
|
||||
RoomChatCommandOutbox(first).enqueue(
|
||||
gatewayId = "gateway-test",
|
||||
sessionKey = "main",
|
||||
text = "post-upgrade media",
|
||||
thinkingLevel = "off",
|
||||
nowMs = System.currentTimeMillis(),
|
||||
attachments =
|
||||
listOf(
|
||||
OutboxAttachmentPayload(type = "image", mimeType = "image/jpeg", fileName = "a.jpg", durationMs = null, bytes = bytes),
|
||||
),
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
queuedId = queued.item.id
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
// Process-restart analog: a fresh open must recover the exact bytes.
|
||||
val reopened = ChatCacheDatabase.open(context, databaseName)
|
||||
try {
|
||||
val loaded = RoomChatCommandOutbox(reopened).loadAttachments(queuedId)
|
||||
assertEquals(1, loaded.size)
|
||||
assertTrue(bytes.contentEquals(loaded.single().bytes))
|
||||
} finally {
|
||||
reopened.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createV2Fixture(path: String) {
|
||||
SQLiteDatabase.openOrCreateDatabase(path, null).use { database ->
|
||||
val now = System.currentTimeMillis()
|
||||
@@ -105,6 +156,15 @@ class ChatCacheDatabaseMigrationTest {
|
||||
lastError = "original failure",
|
||||
createdAtMs = now + 3,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "legacy-command",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 4,
|
||||
text = "/clear",
|
||||
)
|
||||
database.version = 2
|
||||
}
|
||||
}
|
||||
@@ -116,12 +176,13 @@ class ChatCacheDatabaseMigrationTest {
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
createdAtMs: Long,
|
||||
text: String = id,
|
||||
) {
|
||||
database.execSQL(
|
||||
"INSERT INTO outbox_commands " +
|
||||
"(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(id, "gateway-test", "main", id, "off", createdAtMs, status, retryCount, lastError),
|
||||
arrayOf<Any?>(id, "gateway-test", "main", text, "off", createdAtMs, status, retryCount, lastError),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,7 +127,7 @@ class RoomChatCommandOutboxTest {
|
||||
store.expireStale("gateway-a", nowMs = now)
|
||||
assertEquals(ChatOutboxStatus.Failed, store.load("gateway-a").single().status)
|
||||
|
||||
assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = stale.id, nowMs = now))
|
||||
assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = stale.id, nowMs = now, gatedEpoch = null))
|
||||
store.expireStale("gateway-a", nowMs = now)
|
||||
|
||||
val retried = store.load("gateway-a").single()
|
||||
@@ -143,7 +143,7 @@ class RoomChatCommandOutboxTest {
|
||||
val failed = store.enqueueQueued("gateway a failed", nowMs = 10, gatewayId = "gateway-a")
|
||||
store.updateStatus(failed.id, ChatOutboxStatus.Failed, retryCount = 1, lastError = "boom")
|
||||
|
||||
val changed = store.requeueForRetry(gatewayId = "gateway-b", id = failed.id, nowMs = 20)
|
||||
val changed = store.requeueForRetry(gatewayId = "gateway-b", id = failed.id, nowMs = 20, gatedEpoch = null)
|
||||
|
||||
assertEquals(0, changed)
|
||||
val untouched = store.load("gateway-a").single()
|
||||
@@ -157,11 +157,11 @@ class RoomChatCommandOutboxTest {
|
||||
runTest {
|
||||
val failed = store.enqueueQueued("retry once", nowMs = 10)
|
||||
store.updateStatus(failed.id, ChatOutboxStatus.Failed, retryCount = 1, lastError = "boom")
|
||||
assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = failed.id, nowMs = 20))
|
||||
assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = failed.id, nowMs = 20, gatedEpoch = null))
|
||||
store.updateStatus(failed.id, ChatOutboxStatus.Sending, retryCount = 0, lastError = null)
|
||||
val sendingCreatedAt = store.load("gateway-a").single().createdAtMs
|
||||
|
||||
val changed = store.requeueForRetry(gatewayId = "gateway-a", id = failed.id, nowMs = 30)
|
||||
val changed = store.requeueForRetry(gatewayId = "gateway-a", id = failed.id, nowMs = 30, gatedEpoch = null)
|
||||
|
||||
assertEquals(0, changed)
|
||||
val untouched = store.load("gateway-a").single()
|
||||
@@ -204,4 +204,220 @@ class RoomChatCommandOutboxTest {
|
||||
|
||||
assertEquals(listOf("for other"), store.load("gateway-a").map { it.text })
|
||||
}
|
||||
|
||||
private fun payload(
|
||||
bytes: ByteArray,
|
||||
fileName: String = "a.jpg",
|
||||
type: String = "image",
|
||||
mimeType: String = "image/jpeg",
|
||||
durationMs: Long? = null,
|
||||
): OutboxAttachmentPayload = OutboxAttachmentPayload(type = type, mimeType = mimeType, fileName = fileName, durationMs = durationMs, bytes = bytes)
|
||||
|
||||
@Test
|
||||
fun attachmentBytesRoundTripExactlyAcrossStoreReopen() =
|
||||
runTest {
|
||||
// Spans multiple chunks to prove chunked reassembly is byte-exact and ordered.
|
||||
val big = ByteArray(OUTBOX_ATTACHMENT_CHUNK_BYTES + 1234) { (it % 251).toByte() }
|
||||
val small = byteArrayOf(5, 4, 3)
|
||||
val queued =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "with media",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 10,
|
||||
attachments =
|
||||
listOf(
|
||||
payload(big, fileName = "big.jpg"),
|
||||
payload(small, fileName = "note.m4a", type = "audio", mimeType = "audio/mp4", durationMs = 900L),
|
||||
),
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
|
||||
val loadedItem = store.load("gateway-a").single()
|
||||
assertEquals(listOf("big.jpg", "note.m4a"), loadedItem.attachments.map { it.fileName })
|
||||
assertEquals(listOf(big.size.toLong(), small.size.toLong()), loadedItem.attachments.map { it.byteLength })
|
||||
assertEquals(900L, loadedItem.attachments[1].durationMs)
|
||||
|
||||
val loaded = store.loadAttachments(queued.item.id)
|
||||
assertTrue(big.contentEquals(loaded[0].bytes))
|
||||
assertTrue(small.contentEquals(loaded[1].bytes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perCommandAttachmentByteCapRefusesOversizedSends() =
|
||||
runTest {
|
||||
val oversized = ByteArray((OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES + 1).toInt())
|
||||
val refused =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "too big",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 10,
|
||||
attachments = listOf(payload(oversized)),
|
||||
)
|
||||
assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, refused)
|
||||
assertTrue(store.load("gateway-a").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayAttachmentByteBudgetRefusesWhenExhaustedAndRecoversAfterDelete() =
|
||||
runTest {
|
||||
val chunk = ByteArray(OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES.toInt())
|
||||
val stored = mutableListOf<String>()
|
||||
var index = 0
|
||||
while (true) {
|
||||
val result =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "bulk $index",
|
||||
thinkingLevel = "off",
|
||||
nowMs = index.toLong(),
|
||||
attachments = listOf(payload(chunk)),
|
||||
)
|
||||
if (result !is ChatOutboxEnqueueResult.Queued) {
|
||||
assertEquals(ChatOutboxEnqueueResult.StorageFull, result)
|
||||
break
|
||||
}
|
||||
stored += result.item.id
|
||||
index += 1
|
||||
}
|
||||
assertTrue(stored.isNotEmpty())
|
||||
|
||||
// Deleting a queued row releases its bytes, so admission recovers.
|
||||
store.delete(stored.first())
|
||||
val retried =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "fits again",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 999,
|
||||
attachments = listOf(payload(chunk)),
|
||||
)
|
||||
assertTrue(retried is ChatOutboxEnqueueResult.Queued)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmDeliveredRetiresRowsAndTheirAttachmentBytesAtomically() =
|
||||
runTest {
|
||||
val bytes = byteArrayOf(1, 2, 3)
|
||||
val queued =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "confirmed",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 10,
|
||||
attachments = listOf(payload(bytes)),
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
store.updateStatus(queued.item.id, ChatOutboxStatus.Accepted, retryCount = 0, lastError = null)
|
||||
val keep = store.enqueueQueued("kept", nowMs = 20)
|
||||
|
||||
assertEquals(1, store.confirmDelivered(setOf(queued.item.id, "missing-row")))
|
||||
|
||||
assertEquals(listOf(keep.id), store.load("gateway-a").map { it.id })
|
||||
assertTrue(store.loadAttachments(queued.item.id).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearGatewayAndSessionDeleteAlsoDropAttachmentBytes() =
|
||||
runTest {
|
||||
val a =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "a",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 10,
|
||||
attachments = listOf(payload(byteArrayOf(1))),
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
val b =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-b",
|
||||
sessionKey = "other",
|
||||
text = "b",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 20,
|
||||
attachments = listOf(payload(byteArrayOf(2))),
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
|
||||
store.deleteForSession("gateway-b", "other")
|
||||
store.clearGateway("gateway-a")
|
||||
|
||||
assertTrue(store.load("gateway-a").isEmpty())
|
||||
assertTrue(store.load("gateway-b").isEmpty())
|
||||
assertTrue(store.loadAttachments(a.item.id).isEmpty())
|
||||
assertTrue(store.loadAttachments(b.item.id).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinSessionKeyRewritesTheAliasExactlyOnce() =
|
||||
runTest {
|
||||
val queued = store.enqueueQueued("pinned", nowMs = 10)
|
||||
store.pinSessionKey(queued.id, "agent:work:main")
|
||||
assertEquals("agent:work:main", store.load("gateway-a").single().sessionKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatedEpochSurvivesPersistenceAndRetryRestamping() =
|
||||
runTest {
|
||||
val queued =
|
||||
store.enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "/clear",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 10,
|
||||
gatedEpoch = 7L,
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
assertEquals(7L, store.load("gateway-a").single().gatedEpoch)
|
||||
|
||||
store.updateStatus(queued.item.id, ChatOutboxStatus.Failed, retryCount = 0, lastError = OUTBOX_CONNECTION_CHANGED_ERROR)
|
||||
assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = queued.item.id, nowMs = 20, gatedEpoch = 9L))
|
||||
assertEquals(9L, store.load("gateway-a").single().gatedEpoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleAcceptedRowsExpireToDeliveryUnconfirmed() =
|
||||
runTest {
|
||||
val now = 1_000_000_000L
|
||||
val accepted = store.enqueueQueued("acked long ago", nowMs = now - OUTBOX_EXPIRY_MS - 1)
|
||||
store.updateStatus(accepted.id, ChatOutboxStatus.Accepted, retryCount = 0, lastError = null)
|
||||
|
||||
store.expireStale("gateway-a", nowMs = now)
|
||||
|
||||
val row = store.load("gateway-a").single()
|
||||
assertEquals(ChatOutboxStatus.Failed, row.status)
|
||||
assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, row.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimForSendingIsAtomicAcrossCompetingDispatchers() =
|
||||
runTest {
|
||||
val queued = store.enqueueQueued("claim me", nowMs = 10)
|
||||
|
||||
assertEquals(1, store.claimForSending(queued.id, 0, null))
|
||||
// The losing dispatcher gets 0 and must not send; the row is already claimed.
|
||||
assertEquals(0, store.claimForSending(queued.id, 0, null))
|
||||
assertEquals(ChatOutboxStatus.Sending, store.load("gateway-a").single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requeueForRetryKeepsSameSessionQueuedSuccessorsBehindTheRetriedRow() =
|
||||
runTest {
|
||||
val head = store.enqueueQueued("head", nowMs = 10)
|
||||
val tail = store.enqueueQueued("tail", nowMs = 20)
|
||||
val other = store.enqueueQueued("other", nowMs = 30, sessionKey = "agent:other:main")
|
||||
store.updateStatus(head.id, ChatOutboxStatus.Failed, retryCount = 0, lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR)
|
||||
|
||||
assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = head.id, nowMs = 1_000_000_000L, gatedEpoch = null))
|
||||
|
||||
val byId = store.load("gateway-a").associateBy { it.id }
|
||||
// The retried head still precedes its session successor; unrelated sessions keep position.
|
||||
assertTrue(byId.getValue(head.id).createdAtMs < byId.getValue(tail.id).createdAtMs)
|
||||
assertEquals(ChatOutboxStatus.Queued, byId.getValue(tail.id).status)
|
||||
assertEquals(30L, byId.getValue(other.id).createdAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
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 org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -88,6 +90,41 @@ class ChatTimelineTest {
|
||||
assertEquals(null, timeline.latestUserMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outboxRowsHideOnceTheirUserTurnIsVisibleAsAMessage() {
|
||||
val visible =
|
||||
ChatOutboxItem(
|
||||
id = "visible-row",
|
||||
sessionKey = "main",
|
||||
text = "still queued",
|
||||
thinkingLevel = "off",
|
||||
createdAtMs = 1,
|
||||
status = ChatOutboxStatus.Queued,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
)
|
||||
val consumed =
|
||||
visible.copy(
|
||||
id = "consumed-row",
|
||||
status = ChatOutboxStatus.Accepted,
|
||||
createdAtMs = 2,
|
||||
)
|
||||
val optimisticCopy =
|
||||
textMessage(id = "m1", role = "user", text = "sent already")
|
||||
.copy(idempotencyKey = "consumed-row:user")
|
||||
|
||||
val filtered =
|
||||
outboxItemsForSession(
|
||||
items = listOf(visible, consumed),
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:work:main",
|
||||
messages = listOf(optimisticCopy),
|
||||
)
|
||||
|
||||
// A row whose turn already renders as a message never shows a second bubble.
|
||||
assertEquals(listOf("visible-row"), filtered.map { it.id })
|
||||
}
|
||||
|
||||
private fun textMessage(
|
||||
id: String,
|
||||
role: String,
|
||||
|
||||
@@ -274,6 +274,7 @@ The Android Chat tab supports session selection (default `main`, plus other exis
|
||||
|
||||
- History: `chat.history` (display-normalized — inline directive tags, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, and truncated variants), and leaked ASCII/full-width model control tokens are stripped; silent-token assistant rows such as exact `NO_REPLY` / `no_reply` are omitted; oversized rows can be replaced with placeholders)
|
||||
- Send: `chat.send`
|
||||
- Durable sending: every send (text, picked images, and voice notes) is journaled to a per-gateway on-device outbox before any network attempt, so app termination cannot lose submitted input. Sends queued while offline deliver in order on reconnect with stable idempotency keys, and a send is retired only after the turn is visible in canonical `chat.history` — an acknowledgement alone is not treated as proof of delivery. Ambiguous outcomes (lost acknowledgement, app killed mid-send, gateway restart before the transcript write) surface as visible rows with explicit **Retry**/**Delete** instead of auto-resending. Slash commands never auto-replay across a reconnect; they park for explicit retry. The queue is bounded (50 messages and 48 MB of attachment bytes per gateway) and unsent rows expire after 48 hours. Composer drafts that were never submitted are not process-durable.
|
||||
- Push updates (best-effort): `chat.subscribe` -> `event:"chat"`
|
||||
- Listen: long-press an assistant message and choose **Listen** to hear it; audio renders via gateway `tts.speak` with the configured TTS provider chain, and on-device system TTS is used when the gateway cannot render audio. Playback stops on session switch, new chat, app backgrounding, or chat close.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user