From f8eed6dfb0432350b21fb961dd3b59e640364b99 Mon Sep 17 00:00:00 2001 From: Jebran Syed Date: Sun, 9 Aug 2026 19:31:10 -0700 Subject: [PATCH 1/4] Preserve mobile-2 chat state across process death, with retention and reset The chat sample kept its entire session in memory, so the transcript was lost whenever Android tore the app down. Configuration changes destroyed it immediately, and backgrounding the app lost it as soon as the process was reclaimed. The server cannot fill the gap: agent-server exposes no history RPC, so the client has to own its transcript. Hoist the session into a ViewModel so rotation, theme, font-scale and locale changes no longer tear down the socket, and mirror the transcript to SharedPreferences via ChatSessionStore so it also survives process death. The joined conversationId is stored alongside the messages and the restored transcript is dropped if the server hands back a different conversation, rather than showing history the agent has no memory of. Restored messages are always sealed as final, otherwise they render as "Responding..." forever and can be retargeted by later streaming updates. Writes are debounced, because the message list re-emits on every streamed chunk while SharedPreferences rewrites its whole file per commit. Because viewModelScope is cancelled before onCleared runs, the debounced writer is already dead by teardown, so onCleared also flushes synchronously - otherwise the last few hundred milliseconds are lost, as are the bubbles that disconnect() seals during teardown, which no writer could ever observe. Bound the stored data on both axes. Size is capped at the newest 200 messages, and messages older than 30 days are dropped on save and on load so they expire even while the app is not running. A load that drops anything rewrites the file immediately, so expired messages are erased rather than merely hidden. Exclude the transcript from Auto Backup, which previously copied it to the user's Google account by way of the untouched template rules, and add a confirmed "New chat" action that clears it from both the screen and disk. Voice input is now auto-sent on a final recognition result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- android/samples/mobile-2/README.md | 53 +++ android/samples/mobile-2/app/build.gradle.kts | 1 + .../example/typeagentchat/ChatSessionStore.kt | 257 +++++++++++++++ .../example/typeagentchat/ChatViewModel.kt | 304 ++++++++++++++++++ .../com/example/typeagentchat/MainActivity.kt | 175 ++++++---- .../java/com/example/typeagentchat/Message.kt | 13 +- .../example/typeagentchat/WebSocketManager.kt | 40 +++ .../app/src/main/res/xml/backup_rules.xml | 16 +- .../main/res/xml/data_extraction_rules.xml | 19 +- .../ChatSessionSerializerTest.kt | 234 ++++++++++++++ .../mobile-2/gradle/libs.versions.toml | 1 + 11 files changed, 1023 insertions(+), 90 deletions(-) create mode 100644 android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt create mode 100644 android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt create mode 100644 android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt diff --git a/android/samples/mobile-2/README.md b/android/samples/mobile-2/README.md index 27464b84af..bdb39caf31 100644 --- a/android/samples/mobile-2/README.md +++ b/android/samples/mobile-2/README.md @@ -13,9 +13,62 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server - Incremental assistant response streaming into a single bubble per `requestId`, honouring the SDK's `DisplayAppendMode` (`inline`, `block`, `temporary`, `step`) and `DisplayMessageKind` styling the same way the Electron shell does +- Chat history that survives both configuration changes and process death (see + [Session persistence](#session-persistence)) - DevTunnel authentication via `X-Tunnel-Authorization` header - Build-time configuration via environment variables and `BuildConfig` +## Session persistence + +The chat session is owned by a `ViewModel`, so rotation, theme, font-scale and +locale changes no longer tear down the socket and the transcript. + +A `ViewModel` dies with its process though, which Android does routinely once the +app is backgrounded. The transcript is therefore mirrored to `SharedPreferences` +by `ChatSessionStore` and restored on the next start, capped at the most recent +`ChatSessionSerializer.MAX_PERSISTED_MESSAGES` messages. The server cannot fill +this gap: `agent-server` exposes no history RPC, so the client has to own its own +transcript. + +The joined `conversationId` is stored alongside the messages. `joinConversation` +normally returns the same conversation every time, so a restored transcript still +matches what the agent remembers; if the server does hand back a different +conversation the stale transcript is dropped rather than shown as history the +agent has no memory of. + +### What is stored, and for how long + +Everything lives in one private `SharedPreferences` file inside the app's own +sandbox (`typeagent_chat_session.xml`), readable only by this app. Nothing is +written to shared or external storage. + +Two independent limits keep it from growing without end: + +| Limit | Constant | Effect | +|---|---|---| +| Size | `MAX_PERSISTED_MESSAGES` (200) | Only the newest 200 messages are kept. A full 200-message transcript measures ~59 KB. | +| Age | `MAX_MESSAGE_AGE_MILLIS` (30 days) | Messages older than the window are deleted, including while the app is not running. | + +Retention runs on both save and load. Because a load only *filters* what it +reads, a read that drops anything immediately rewrites the file, so expired +messages are erased rather than merely hidden. Expiry is applied to what is +stored, not to what is already on screen: messages already visible stay for the +rest of the session rather than disappearing mid-conversation. + +Saving is debounced (`ChatViewModel.SAVE_DEBOUNCE_MS`). `SharedPreferences` +rewrites its entire file on every commit and the message list re-emits on every +streaming chunk, so an undebounced save would rewrite the whole blob dozens of +times per reply. + +The transcript is excluded from Android's Auto Backup (`backup_rules.xml` and +`data_extraction_rules.xml`), so conversations are never uploaded to the user's +cloud account. A direct device-to-device transfer does carry it, since that +copies straight to the new phone without a cloud round trip. + +**New chat** in the header clears the transcript from both the screen and disk +after a confirmation. It is a client-side reset: the agent keeps its own +server-side memory of the conversation. + ## Device actions The app implements the `takeAction` client actions emitted by the `androidMobile` diff --git a/android/samples/mobile-2/app/build.gradle.kts b/android/samples/mobile-2/app/build.gradle.kts index c779d27dba..5ccb02d424 100644 --- a/android/samples/mobile-2/app/build.gradle.kts +++ b/android/samples/mobile-2/app/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.commonmark) implementation(libs.squareup.okhttp) testImplementation(libs.json) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt new file mode 100644 index 0000000000..d7fb2b243b --- /dev/null +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt @@ -0,0 +1,257 @@ +package com.example.typeagentchat + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject + +/** + * The chat transcript plus the server conversation it belongs to. + * + * [conversationId] is stored so a restored transcript can be checked against the + * conversation the server actually hands back on the next join. If the server + * has moved on to a different conversation the old transcript is no longer a + * record of anything the agent remembers. + */ +data class PersistedChatSession( + val conversationId: String?, + val messages: List +) { + companion object { + val EMPTY = PersistedChatSession(conversationId = null, messages = emptyList()) + } +} + +/** + * A decoded session plus the number of stored messages that retention discarded + * (aged out, or trimmed by the message cap). + * + * A non-zero count means the file on disk still contains messages the app will + * no longer show, so the caller should rewrite it to actually delete them. + */ +data class DecodedChatSession( + val session: PersistedChatSession, + val droppedCount: Int +) + + +/** + * Persists the chat transcript so it survives process death. + * + * A `ViewModel` only survives configuration changes, so without this the whole + * conversation disappeared the first time Android reclaimed the backgrounded + * app process. The TypeAgent server cannot fill the gap: it exposes no history + * API (`getChatHistory`, `getMessages` and friends all answer "No invoke + * handler"), so the client has to own its own transcript. + * + * `SharedPreferences` is used rather than Room/DataStore to avoid adding + * dependencies for what is a single small blob. Reads and writes are blocking, + * so callers must keep them off the main thread. + */ +class ChatSessionStore(context: Context) { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): PersistedChatSession { + val raw = prefs.getString(KEY_SESSION, null) ?: return PersistedChatSession.EMPTY + return try { + val decoded = ChatSessionSerializer.decodeDetailed(raw) + if (decoded.droppedCount > 0) { + // Retention filters on read, but the expired messages are still + // sitting in the file until something else triggers a save. + // Purge them now so "deleted" means deleted rather than hidden. + Log.d(TAG, "Purging ${decoded.droppedCount} stale messages from disk") + save(decoded.session) + } + decoded.session + } catch (error: Exception) { + // A partially written or stale-format blob must not brick startup. + Log.e(TAG, "Discarding unreadable persisted chat session", error) + clear() + PersistedChatSession.EMPTY + } + } + + /** + * Writes with `commit()` rather than `apply()`. Every caller is already off + * the main thread except the teardown flush, which needs the write to have + * actually landed before the process is free to go away - `apply()` only + * queues it. + */ + fun save(session: PersistedChatSession) { + try { + prefs.edit() + .putString(KEY_SESSION, ChatSessionSerializer.encode(session)) + .commit() + } catch (error: Exception) { + Log.e(TAG, "Failed to persist chat session", error) + } + } + + fun clear() { + prefs.edit().remove(KEY_SESSION).commit() + } + + private companion object { + private const val TAG = "ChatSessionStore" + + /** + * Backing file is `shared_prefs/$PREFS_NAME.xml`. That exact name is + * excluded from backups in `res/xml/backup_rules.xml` and + * `res/xml/data_extraction_rules.xml`, so renaming it here without + * updating both silently starts uploading transcripts to the cloud. + */ + private const val PREFS_NAME = "typeagent_chat_session" + private const val KEY_SESSION = "session" + } +} + +/** + * JSON encoding for [PersistedChatSession]. + * + * Kept free of Android framework types so the round-trip is covered by plain + * JVM unit tests. + */ +object ChatSessionSerializer { + + /** + * Caps how much transcript is written back to disk. `SharedPreferences` + * holds the whole blob in memory and rewrites it in full on every commit, + * so an unbounded transcript would keep growing the cost of every save. + */ + const val MAX_PERSISTED_MESSAGES = 200 + + /** + * How long a stored message is kept. The message cap alone bounds *size* + * but not *time*: without this the newest 200 messages would sit on the + * device forever. Retention is applied on both save and load, so messages + * also age out while the app is not running. + */ + const val MAX_MESSAGE_AGE_MILLIS = 30L * 24 * 60 * 60 * 1000 + + private const val VERSION = 1 + + fun encode( + session: PersistedChatSession, + now: Long = System.currentTimeMillis() + ): String { + val messages = retain(session.messages, now) + val array = JSONArray() + messages.forEach { array.put(encodeMessage(it)) } + return JSONObject() + .put("version", VERSION) + .putOpt("conversationId", session.conversationId) + .put("messages", array) + .toString() + } + + fun decode( + raw: String, + now: Long = System.currentTimeMillis() + ): PersistedChatSession = decodeDetailed(raw, now).session + + /** + * Same as [decode], but also reports how many stored messages the retention + * window dropped. The caller needs this to know whether the file on disk + * still holds expired content that should be rewritten: filtering on read + * hides old messages, it does not delete them. + */ + fun decodeDetailed( + raw: String, + now: Long = System.currentTimeMillis() + ): DecodedChatSession { + val root = JSONObject(raw) + if (root.optInt("version") != VERSION) { + return DecodedChatSession(PersistedChatSession.EMPTY, droppedCount = 0) + } + val conversationId = root.optString("conversationId").takeIf { it.isNotBlank() } + val array = root.optJSONArray("messages") ?: JSONArray() + val messages = ArrayList(array.length()) + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + decodeMessage(item, now)?.let { messages += it } + } + val retained = retain(messages, now) + return DecodedChatSession( + session = PersistedChatSession( + conversationId = conversationId, + messages = retained + ), + droppedCount = messages.size - retained.size + ) + } + + /** + * Drops messages that are older than the retention window, then keeps only + * the newest [MAX_PERSISTED_MESSAGES]. + * + * A timestamp in the future (clock changes, timezone-induced skew) is + * treated as current rather than expired, so a wrong clock cannot silently + * delete a live conversation. + */ + private fun retain(messages: List, now: Long): List { + return messages + .filter { now - it.timestampMillis <= MAX_MESSAGE_AGE_MILLIS } + .takeLast(MAX_PERSISTED_MESSAGES) + } + + private fun encodeMessage(message: Message): JSONObject { + val segments = JSONArray() + message.segments.forEach { segment -> + segments.put( + JSONObject() + .put("text", segment.text) + .put("format", segment.format.name) + .put("kind", segment.kind.name) + ) + } + return JSONObject() + .put("id", message.id) + .put("isUser", message.isUser) + .putOpt("requestId", message.requestId) + .put("timestamp", message.timestampMillis) + .put("segments", segments) + } + + private fun decodeMessage(item: JSONObject, now: Long): Message? { + val segmentsArray = item.optJSONArray("segments") ?: return null + val segments = ArrayList(segmentsArray.length()) + for (index in 0 until segmentsArray.length()) { + val segment = segmentsArray.optJSONObject(index) ?: continue + segments += MessageSegment( + text = segment.optString("text"), + format = parseFormat(segment.optString("format")), + kind = MessageKind.parse(segment.optString("kind")) + ) + } + if (segments.isEmpty()) { + return null + } + val id = item.optString("id").takeIf { it.isNotBlank() } ?: return null + return Message( + id = id, + segments = segments, + isUser = item.optBoolean("isUser"), + requestId = item.optString("requestId").takeIf { it.isNotBlank() }, + // Restored bubbles are always sealed. Nothing can still be streaming + // into them after a restart, and an unsealed bubble would both render + // "Responding..." forever and be a candidate for the + // `indexOfLast { !isUser && !isFinal }` lookup in + // WebSocketManager.finalizeAssistantMessage. + isFinal = true, + // Transcripts written before timestamps existed are treated as + // current, so upgrading the app grants them one full retention + // window rather than deleting the user's history on first launch. + timestampMillis = item.optLong("timestamp", now).takeIf { it > 0L } ?: now + ) + } + + private fun parseFormat(raw: String?): MessageFormat { + return when (raw?.trim()?.uppercase()) { + MessageFormat.MARKDOWN.name -> MessageFormat.MARKDOWN + else -> MessageFormat.TEXT + } + } +} diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt new file mode 100644 index 0000000000..cdcc42f0b9 --- /dev/null +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt @@ -0,0 +1,304 @@ +package com.example.typeagentchat + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A client action that the agent asked the app to perform and that can only be + * carried out by an Activity (it ends in `startActivity`). + * + * These are delivered as events rather than state: the ViewModel outlives the + * Activity across configuration changes, so it must not hold a reference to the + * Activity that will ultimately handle them. + */ +internal sealed interface ClientAction { + data class Alarm(val action: SetAlarmAction) : ClientAction + data class Timer(val action: SetTimerAction) : ClientAction + data class SearchNearby(val action: SearchNearbyAction) : ClientAction +} + +/** + * Owns the chat session so it survives configuration changes, and persists it + * so it also survives process death. + * + * The [WebSocketManager] used to be a field on `MainActivity`, which meant a + * theme change, rotation, font-scale change or locale change destroyed the + * socket and the entire message list and started a brand new conversation. + * `WebSocketManager.disconnect()` also shuts the OkHttp client's executor down + * for good, so the instance could not be reused even in principle. Holding it + * here scopes it to the logical screen instead of the Activity instance. + * + * A ViewModel still dies with its process though, which is routine as soon as + * the user switches to another app. The transcript is therefore mirrored to + * [ChatSessionStore] and restored on the next start. The server cannot supply + * that history - it exposes no history RPC - but it does keep handing back the + * same conversation, so a restored transcript still lines up with what the + * agent remembers. + */ +class ChatViewModel(application: Application) : AndroidViewModel(application) { + + private val webSocketManager = WebSocketManager() + private val sessionStore = ChatSessionStore(application) + + val messages: StateFlow> = webSocketManager.messages + val connectionStatus: StateFlow = webSocketManager.connectionStatus + val pendingYesNoPrompt: StateFlow = webSocketManager.pendingYesNoPrompt + + private val _inputText = MutableStateFlow("") + val inputText: StateFlow = _inputText + + /** + * Buffered so an action that arrives while the Activity is being recreated + * is replayed once it resumes instead of being dropped by the RESUMED guard + * in `MainActivity.launchExternalIntent`. + */ + private val clientActionEvents = Channel(Channel.UNLIMITED) + internal val clientActions: Flow = clientActionEvents.receiveAsFlow() + + private var hasConnected = false + + /** The conversation the restored transcript was recorded against. */ + @Volatile + private var restoredConversationId: String? = null + + /** + * Completes once the persisted transcript has been read back and handed to + * the socket. Connecting waits on it so a slow disk read can never race the + * first inbound message. + */ + private val restored = CompletableDeferred() + + init { + webSocketManager.setClientActionHandler(object : WebSocketManager.ClientActionHandler { + override fun onSetAlarm(action: SetAlarmAction) { + clientActionEvents.trySend(ClientAction.Alarm(action)) + } + + override fun onSetTimer(action: SetTimerAction) { + clientActionEvents.trySend(ClientAction.Timer(action)) + } + + override fun onSearchNearby(action: SearchNearbyAction) { + clientActionEvents.trySend(ClientAction.SearchNearby(action)) + } + }) + + viewModelScope.launch { + val session = withContext(Dispatchers.IO) { sessionStore.load() } + restoredConversationId = session.conversationId + webSocketManager.restoreMessages(session.messages) + Log.d( + TAG, + "Restored ${session.messages.size} messages " + + "for conversationId=${session.conversationId ?: "none"}" + ) + restored.complete(Unit) + } + + observeSessionForPersistence() + reconcileRestoredTranscript() + } + + /** + * Mirrors the transcript back to disk. + * + * Writes are debounced because `_messages` is re-emitted for every streamed + * display chunk, while `SharedPreferences` rewrites the whole file on each + * commit - so saving per emission would re-encode and rewrite the entire + * transcript dozens of times for a single agent response. `collectLatest` + * cancels the pending delay whenever a newer value arrives, so a burst of + * chunks collapses into one write once the stream settles. + * + * The initial value is dropped so the empty list the socket starts with + * cannot overwrite a transcript that is still being read back. + */ + private fun observeSessionForPersistence() { + viewModelScope.launch { + restored.await() + webSocketManager.messages.drop(1).collectLatest { messages -> + delay(SAVE_DEBOUNCE_MS) + val conversationId = + webSocketManager.joinedConversationId.value ?: restoredConversationId + withContext(Dispatchers.IO) { + sessionStore.save( + PersistedChatSession( + conversationId = conversationId, + messages = messages + ) + ) + } + } + } + } + + /** + * Drops a restored transcript that belongs to a conversation the server has + * since replaced, so the user is not left reading history the agent has no + * memory of. + */ + private fun reconcileRestoredTranscript() { + viewModelScope.launch { + restored.await() + val previous = restoredConversationId ?: return@launch + val joined = webSocketManager.joinedConversationId.filterNotNull().first() + if (joined == previous) { + return@launch + } + Log.w( + TAG, + "Server conversation changed ($previous -> $joined); dropping stale transcript" + ) + webSocketManager.clearMessages() + } + } + + /** Connects on first use only, so Activity recreation does not restart the session. */ + fun connectIfNeeded(url: String, tunnelToken: String?) { + if (hasConnected) { + return + } + hasConnected = true + viewModelScope.launch { + restored.await() + webSocketManager.connect(url = url, tunnelToken = tunnelToken) + } + } + + fun reconnect(url: String, tunnelToken: String?) { + hasConnected = true + viewModelScope.launch { + restored.await() + webSocketManager.connect(url = url, tunnelToken = tunnelToken) + } + } + + fun onInputTextChange(text: String) { + _inputText.value = text + } + + private val isConnected: Boolean + get() = connectionStatus.value.state == ConnectionStatus.State.CONNECTED + + val canSend: Boolean + get() = isConnected && _inputText.value.isNotBlank() + + /** @return true when the message was handed to the socket and the input was cleared. */ + fun submitMessage(): Boolean { + return sendText(_inputText.value) + } + + /** + * Sends dictated speech straight through instead of parking it in the input + * box and waiting for a Send tap. If the socket is down the text is kept in + * the input box so nothing spoken is lost. + */ + fun onRecognizedText(recognizedText: String): Boolean { + val merged = mergeSpeechInputText( + currentText = _inputText.value, + recognizedText = recognizedText + ) + if (!isConnected) { + _inputText.value = merged + return false + } + return sendText(merged) + } + + fun respondToPendingYesNo(yes: Boolean): Boolean = webSocketManager.respondToPendingYesNo(yes) + + /** + * Starts a new chat: clears the on-screen transcript and the copy on disk. + * + * This is a client-side reset only. `joinConversation` keeps returning the + * same server-side conversation, so the agent's own memory is untouched - + * the server exposes no RPC to start a fresh one. + */ + fun startNewChat() { + webSocketManager.clearMessages() + viewModelScope.launch { + withContext(Dispatchers.IO) { sessionStore.clear() } + } + } + + private fun sendText(text: String): Boolean { + val message = text.trim() + if (!isConnected || message.isBlank()) { + return false + } + webSocketManager.sendMessage(message) + _inputText.value = "" + return true + } + + override fun onCleared() { + webSocketManager.setClientActionHandler(null) + webSocketManager.disconnect() + clientActionEvents.close() + flushSessionToDisk() + super.onCleared() + } + + /** + * Writes the transcript one last time as the screen goes away. + * + * `viewModelScope` is cancelled *before* [onCleared] runs, which takes the + * debounced writer down with it. Without this, anything changed in the last + * [SAVE_DEBOUNCE_MS] never reaches disk, and neither do the open bubbles + * that [WebSocketManager.disconnect] just sealed above - those are mutated + * after the writer is already dead, so they could never be saved at all. + * + * Skipped until the restore has finished: a teardown that races the initial + * read would otherwise persist the still-empty transcript over the stored + * one and lose the whole history. + */ + private fun flushSessionToDisk() { + if (!restored.isCompleted) { + return + } + sessionStore.save( + PersistedChatSession( + conversationId = webSocketManager.joinedConversationId.value + ?: restoredConversationId, + messages = webSocketManager.messages.value + ) + ) + } + + private companion object { + private const val TAG = "ChatViewModel" + + /** + * Long enough to collapse a burst of streamed display chunks into one + * write, short enough that an unexpected process kill loses at most + * this much of the transcript. + */ + private const val SAVE_DEBOUNCE_MS = 400L + } +} + +internal fun mergeSpeechInputText( + currentText: String, + recognizedText: String +): String { + return if (currentText.isBlank()) { + recognizedText + } else { + "$currentText $recognizedText" + } +} diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt index b90b854dae..39dc95b203 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -35,6 +37,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -68,39 +71,38 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.example.typeagentchat.ui.theme.TypeAgentChatTheme +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - private val webSocketManager = WebSocketManager() + private val viewModel: ChatViewModel by viewModels() private val tunnelUrl = BuildConfig.TYPEAGENT_SERVER_URL.trim() private val tunnelToken = BuildConfig.TYPEAGENT_TUNNEL_TOKEN.trim().ifBlank { null } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - webSocketManager.setClientActionHandler(object : WebSocketManager.ClientActionHandler { - override fun onSetAlarm(action: SetAlarmAction) { - runOnUiThread { launchSetAlarmIntent(action) } - } - - override fun onSetTimer(action: SetTimerAction) { - runOnUiThread { launchSetTimerIntent(action) } - } - - override fun onSearchNearby(action: SearchNearbyAction) { - runOnUiThread { launchSearchNearbyIntent(action) } + viewModel.connectIfNeeded(url = tunnelUrl, tunnelToken = tunnelToken) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.clientActions.collect { action -> + when (action) { + is ClientAction.Alarm -> launchSetAlarmIntent(action.action) + is ClientAction.Timer -> launchSetTimerIntent(action.action) + is ClientAction.SearchNearby -> launchSearchNearbyIntent(action.action) + } + } } - }) - webSocketManager.connect( - url = tunnelUrl, - tunnelToken = tunnelToken - ) + } setContent { TypeAgentChatTheme { ChatApp( - webSocketManager = webSocketManager, + viewModel = viewModel, tunnelUrl = tunnelUrl, tunnelToken = tunnelToken ) @@ -108,12 +110,6 @@ class MainActivity : ComponentActivity() { } } - override fun onDestroy() { - webSocketManager.setClientActionHandler(null) - webSocketManager.disconnect() - super.onDestroy() - } - private fun launchSetAlarmIntent(action: SetAlarmAction) { val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply { putExtra(AlarmClock.EXTRA_HOUR, action.hour) @@ -249,34 +245,31 @@ class MainActivity : ComponentActivity() { @Composable private fun ChatApp( - webSocketManager: WebSocketManager, + viewModel: ChatViewModel, tunnelUrl: String, tunnelToken: String? ) { - val messages by webSocketManager.messages.collectAsState() - val connectionStatus by webSocketManager.connectionStatus.collectAsState() - val pendingYesNoPrompt by webSocketManager.pendingYesNoPrompt.collectAsState() - var inputText by remember { mutableStateOf("") } + val messages by viewModel.messages.collectAsState() + val connectionStatus by viewModel.connectionStatus.collectAsState() + val pendingYesNoPrompt by viewModel.pendingYesNoPrompt.collectAsState() + val inputText by viewModel.inputText.collectAsState() val listState = rememberLazyListState() val focusManager = LocalFocusManager.current - val canSend = connectionStatus.state == ConnectionStatus.State.CONNECTED && inputText.isNotBlank() + val isConnected = connectionStatus.state == ConnectionStatus.State.CONNECTED + val canSend = isConnected && inputText.isNotBlank() + val voiceInput = rememberVoiceInputController( onRecognizedText = { recognizedText -> - inputText = mergeSpeechInputText( - currentText = inputText, - recognizedText = recognizedText - ) + if (viewModel.onRecognizedText(recognizedText)) { + focusManager.clearFocus() + } } ) fun submitMessage() { - if (!canSend) { - return + if (viewModel.submitMessage()) { + focusManager.clearFocus() } - val message = inputText.trim() - webSocketManager.sendMessage(message) - inputText = "" - focusManager.clearFocus() } LaunchedEffect(messages.size) { @@ -290,14 +283,18 @@ private fun ChatApp( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), + .padding(16.dp) + .imePadding(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - ChatHeader() + ChatHeader( + canStartNewChat = messages.isNotEmpty(), + onNewChat = { viewModel.startNewChat() } + ) ConnectionStatusIndicator( status = connectionStatus, onReconnect = { - webSocketManager.connect( + viewModel.reconnect( url = tunnelUrl, tunnelToken = tunnelToken ) @@ -342,15 +339,15 @@ private fun ChatApp( ChatInputBar( inputText = inputText, - onInputTextChange = { inputText = it }, - isConnected = connectionStatus.state == ConnectionStatus.State.CONNECTED, + onInputTextChange = { viewModel.onInputTextChange(it) }, + isConnected = isConnected, canSend = canSend, onSend = { submitMessage() }, isVoiceInputAvailable = voiceInput.isAvailable, onVoiceInputClick = voiceInput.onStartRequested, pendingYesNoPrompt = pendingYesNoPrompt, - onConfirmYes = { webSocketManager.respondToPendingYesNo(true) }, - onConfirmNo = { webSocketManager.respondToPendingYesNo(false) } + onConfirmYes = { viewModel.respondToPendingYesNo(true) }, + onConfirmNo = { viewModel.respondToPendingYesNo(false) } ) } } @@ -450,17 +447,6 @@ private fun rememberVoiceInputController( } } -private fun mergeSpeechInputText( - currentText: String, - recognizedText: String -): String { - return if (currentText.isBlank()) { - recognizedText - } else { - "$currentText $recognizedText" - } -} - @Composable private fun ChatInputBar( inputText: String, @@ -568,28 +554,75 @@ private fun ChatInputBar( } @Composable -private fun ChatHeader() { +private fun ChatHeader( + canStartNewChat: Boolean, + onNewChat: () -> Unit +) { + var showConfirmation by remember { mutableStateOf(false) } + Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), tonalElevation = 3.dp ) { - Column( + Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = "TypeAgent Chat", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - Text( - text = "A simple local chat client for your TypeAgent server.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "TypeAgent Chat", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "A simple local chat client for your TypeAgent server.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton( + onClick = { showConfirmation = true }, + enabled = canStartNewChat + ) { + Text("New chat") + } } } + + // Clearing deletes the only copy of the transcript, so it is confirmed + // rather than fired on a single stray tap. + if (showConfirmation) { + AlertDialog( + onDismissRequest = { showConfirmation = false }, + title = { Text("Start a new chat?") }, + text = { + Text( + "This deletes the messages on this device and cannot be undone. " + + "The agent keeps its own memory of the conversation." + ) + }, + confirmButton = { + TextButton( + onClick = { + showConfirmation = false + onNewChat() + } + ) { + Text("Start new chat") + } + }, + dismissButton = { + TextButton(onClick = { showConfirmation = false }) { + Text("Cancel") + } + } + ) + } } @Composable diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt index 465825ccb4..021fbb087a 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt @@ -7,19 +7,26 @@ data class Message( val segments: List, val isUser: Boolean, val requestId: String? = null, - val isFinal: Boolean = false + val isFinal: Boolean = false, + /** + * When the message was created, as epoch milliseconds. Persisted so stored + * transcripts can be aged out - see [ChatSessionSerializer.MAX_MESSAGE_AGE_MILLIS]. + */ + val timestampMillis: Long = System.currentTimeMillis() ) { constructor( text: String, format: MessageFormat = MessageFormat.TEXT, isUser: Boolean, requestId: String? = null, - isFinal: Boolean = false + isFinal: Boolean = false, + timestampMillis: Long = System.currentTimeMillis() ) : this( segments = listOf(MessageSegment(text = text, format = format)), isUser = isUser, requestId = requestId, - isFinal = isFinal + isFinal = isFinal, + timestampMillis = timestampMillis ) val text: String diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt index 77da47c242..5698d47fdd 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt @@ -36,6 +36,14 @@ class WebSocketManager { private val _pendingYesNoPrompt = MutableStateFlow(null) val pendingYesNoPrompt: StateFlow = _pendingYesNoPrompt + /** + * The conversation the server handed back on the last successful join. + * Exposed so the transcript can be reconciled with the server session it + * belongs to after the app process is recreated. + */ + private val _joinedConversationId = MutableStateFlow(null) + val joinedConversationId: StateFlow = _joinedConversationId + private val _connectionStatus = MutableStateFlow( ConnectionStatus( text = "Disconnected", @@ -50,6 +58,37 @@ class WebSocketManager { } } + /** + * Seeds the transcript with messages recovered from disk. + * + * Must be called before [connect]; it deliberately refuses once anything is + * already in the list so a late restore can never clobber live messages. + */ + fun restoreMessages(restored: List) { + if (restored.isEmpty()) { + return + } + synchronized(lock) { + if (_messages.value.isNotEmpty()) { + Log.w(TAG, "Ignoring restore: transcript already has messages") + return + } + _messages.value = restored + } + } + + /** + * Drops the local transcript. Used when the server reports a conversation + * the restored transcript does not belong to. + */ + fun clearMessages() { + synchronized(lock) { + displayThreads.clear() + displayMessageIds.clear() + _messages.value = emptyList() + } + } + fun connect( url: String, tunnelToken: String? = null @@ -286,6 +325,7 @@ class WebSocketManager { conversationId = joinedConversationId connectionId = joinedConnectionId } + _joinedConversationId.value = joinedConversationId Log.d( TAG, diff --git a/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml b/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml index 4df9255824..05dd356cf0 100644 --- a/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml +++ b/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,15 @@ + The chat transcript is personal content and stays on the device it was + written on. Without this it is uploaded to the user's Google account and + restored onto any other device they sign in from. + Path must track ChatSessionStore.PREFS_NAME + ".xml". + --> + \ No newline at end of file diff --git a/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml b/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b0b..13244dc77e 100644 --- a/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,20 @@ - + \ No newline at end of file diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt new file mode 100644 index 0000000000..9d693527f2 --- /dev/null +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt @@ -0,0 +1,234 @@ +package com.example.typeagentchat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatSessionSerializerTest { + + private fun session( + conversationId: String? = "conversation-1", + messages: List + ) = PersistedChatSession(conversationId = conversationId, messages = messages) + + @Test + fun `round trip preserves the transcript`() { + val original = session( + messages = listOf( + Message(text = "what is on my list", isUser = true), + Message( + segments = listOf( + MessageSegment("routed to list", MessageFormat.TEXT, MessageKind.INFO), + MessageSegment("- milk\n- eggs", MessageFormat.MARKDOWN) + ), + isUser = false, + requestId = "req-7", + isFinal = true + ) + ) + ) + + val decoded = ChatSessionSerializer.decode(ChatSessionSerializer.encode(original)) + + assertEquals("conversation-1", decoded.conversationId) + assertEquals(2, decoded.messages.size) + assertEquals("what is on my list", decoded.messages[0].text) + assertTrue(decoded.messages[0].isUser) + assertEquals(original.messages[0].id, decoded.messages[0].id) + + val agent = decoded.messages[1] + assertEquals("req-7", agent.requestId) + assertEquals(MessageFormat.MARKDOWN, agent.format) + assertEquals(MessageKind.INFO, agent.segments[0].kind) + assertEquals("- milk\n- eggs", agent.segments[1].text) + } + + @Test + fun `restored messages are always sealed`() { + val original = session( + messages = listOf( + Message(text = "streaming...", isUser = false, isFinal = false) + ) + ) + + val decoded = ChatSessionSerializer.decode(ChatSessionSerializer.encode(original)) + + // An unsealed restored bubble would render "Responding..." forever and + // could be retro-targeted by WebSocketManager.finalizeAssistantMessage. + assertTrue(decoded.messages.single().isFinal) + } + + @Test + fun `only the most recent messages are persisted`() { + val messages = (1..ChatSessionSerializer.MAX_PERSISTED_MESSAGES + 25).map { + Message(text = "message $it", isUser = true) + } + + val decoded = ChatSessionSerializer.decode( + ChatSessionSerializer.encode(session(messages = messages)) + ) + + assertEquals(ChatSessionSerializer.MAX_PERSISTED_MESSAGES, decoded.messages.size) + assertEquals("message 26", decoded.messages.first().text) + assertEquals( + "message ${ChatSessionSerializer.MAX_PERSISTED_MESSAGES + 25}", + decoded.messages.last().text + ) + } + + @Test + fun `a missing conversation id round trips as null`() { + val decoded = ChatSessionSerializer.decode( + ChatSessionSerializer.encode( + session(conversationId = null, messages = listOf(Message(text = "hi", isUser = true))) + ) + ) + + assertNull(decoded.conversationId) + assertEquals(1, decoded.messages.size) + } + + @Test + fun `an unknown payload version is ignored`() { + val decoded = ChatSessionSerializer.decode( + """{"version":99,"conversationId":"c","messages":[{"id":"a","segments":[]}]}""" + ) + + assertNull(decoded.conversationId) + assertTrue(decoded.messages.isEmpty()) + } + + @Test + fun `messages without usable segments are dropped`() { + val decoded = ChatSessionSerializer.decode( + """{"version":1,"messages":[{"id":"a","isUser":true},{"id":"b","segments":[]}]}""" + ) + + assertTrue(decoded.messages.isEmpty()) + } + + @Test + fun `an empty session encodes and decodes cleanly`() { + val decoded = ChatSessionSerializer.decode( + ChatSessionSerializer.encode(PersistedChatSession.EMPTY) + ) + + assertNull(decoded.conversationId) + assertTrue(decoded.messages.isEmpty()) + } + + @Test + fun `messages past the retention window are not written`() { + val now = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + val messages = listOf( + Message(text = "ancient", isUser = true, timestampMillis = now - 400 * day), + Message(text = "old", isUser = true, timestampMillis = now - 31 * day), + Message(text = "recent", isUser = true, timestampMillis = now - 2 * day), + Message(text = "now", isUser = true, timestampMillis = now) + ) + + val decoded = ChatSessionSerializer.decode( + ChatSessionSerializer.encode(session(messages = messages), now = now), + now = now + ) + + assertEquals(listOf("recent", "now"), decoded.messages.map { it.text }) + } + + @Test + fun `messages expire while the app is not running`() { + val written = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + val raw = ChatSessionSerializer.encode( + session(messages = listOf(Message(text = "hi", isUser = true, timestampMillis = written))), + now = written + ) + + // Same payload, read back long after it was written. + val fresh = ChatSessionSerializer.decode(raw, now = written + 5 * day) + val stale = ChatSessionSerializer.decode(raw, now = written + 45 * day) + + assertEquals(1, fresh.messages.size) + assertTrue(stale.messages.isEmpty()) + } + + @Test + fun `timestamps survive a round trip`() { + val stamp = 1_700_000_000_000L + val decoded = ChatSessionSerializer.decode( + ChatSessionSerializer.encode( + session(messages = listOf(Message(text = "hi", isUser = true, timestampMillis = stamp))), + now = stamp + ), + now = stamp + ) + + assertEquals(stamp, decoded.messages.single().timestampMillis) + } + + @Test + fun `transcripts written before timestamps existed are kept`() { + val now = 1_800_000_000_000L + // A payload from the previous app version: no "timestamp" field. + val legacy = """ + {"version":1,"conversationId":"c","messages":[ + {"id":"a","isUser":true,"segments":[{"text":"legacy","format":"TEXT","kind":"NONE"}]} + ]} + """.trimIndent() + + val decoded = ChatSessionSerializer.decode(legacy, now = now) + + assertEquals("legacy", decoded.messages.single().text) + assertEquals(now, decoded.messages.single().timestampMillis) + } + + @Test + fun `a clock that jumps backwards does not delete live messages`() { + val now = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + // Message stamped in the future relative to `now`. + val messages = listOf(Message(text = "future", isUser = true, timestampMillis = now + 10 * day)) + + val decoded = ChatSessionSerializer.decode( + ChatSessionSerializer.encode(session(messages = messages), now = now), + now = now + ) + + assertEquals(1, decoded.messages.size) + } + + @Test + fun `a read that drops expired messages asks for the file to be rewritten`() { + val written = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + val raw = ChatSessionSerializer.encode( + session( + messages = listOf( + Message(text = "old", isUser = true, timestampMillis = written), + Message(text = "new", isUser = true, timestampMillis = written + 40 * day) + ) + ), + now = written + ) + + val decoded = ChatSessionSerializer.decodeDetailed(raw, now = written + 45 * day) + + // Without this signal the expired message stays in the file: filtering + // on read only hides it from the UI. + assertEquals(1, decoded.droppedCount) + assertEquals(listOf("new"), decoded.session.messages.map { it.text }) + } + + @Test + fun `a read with nothing to drop does not ask for a rewrite`() { + val now = 1_800_000_000_000L + val raw = ChatSessionSerializer.encode( + session(messages = listOf(Message(text = "hi", isUser = true, timestampMillis = now))), + now = now + ) + + assertEquals(0, ChatSessionSerializer.decodeDetailed(raw, now = now).droppedCount) + } +} diff --git a/android/samples/mobile-2/gradle/libs.versions.toml b/android/samples/mobile-2/gradle/libs.versions.toml index b1a0cdc66d..f5dda036fe 100644 --- a/android/samples/mobile-2/gradle/libs.versions.toml +++ b/android/samples/mobile-2/gradle/libs.versions.toml @@ -18,6 +18,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } From 0db9fc00e4776f8629c1bd1ee2445ae4079bf192 Mon Sep 17 00:00:00 2001 From: Jebran Syed Date: Wed, 12 Aug 2026 10:11:04 -0700 Subject: [PATCH 2/4] Address review: resume conversations directly and align naming Rework the mobile-2 persistence change around AgentServer's conversation semantics, per PR review feedback. Resume the saved conversation directly. The joined conversation id is now passed back into `joinConversation` as a connect option, so the client rejoins the exact conversation the restored transcript belongs to instead of joining the default one and reconciling afterwards. If the server answers "Conversation not found" the join retries once against the default and the orphaned transcript is dropped from screen and disk; every other error still surfaces as a connection error, so a transport or auth failure cannot silently move the user into a different conversation. This removes the whole `reconcileRestoredTranscript` round trip. Use conversation terminology. AgentServer reserves "session" for dispatcher runtime state - configuration, caches, agent state - while a conversation is the user-facing identity and chat history. `ChatSessionStore` becomes `ConversationStore`, `PersistedChatSession` becomes `PersistedConversation`, `ChatSessionSerializer` becomes `ConversationSerializer`, and `joinedConversationId` becomes `lastJoinedConversationId` to reflect that it outlives a disconnect. The `SharedPreferences` file name is deliberately left alone: it is pinned by name in `backup_rules.xml` and `data_extraction_rules.xml`, and it names the file already on devices, so renaming it would orphan stored transcripts for no benefit. The reason is now documented on the constant. Fix the meaning of "New chat". The action only ever cleared client-side history, so it is renamed to `clearChatHistory` and relabelled "Clear chat", matching `@clear` on the other canvases. The confirmation dialog no longer implies the conversation itself is affected. A true new-conversation flow (`createConversation` -> join -> persist -> `leaveConversation`) is left for the follow-up that adds `getDisplayHistory` backfill. Tests: 3 new cases pin the fallback predicate, including that a wrapped "Error: Conversation not found" does not trigger it. Full suite: 87 passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../example/typeagentchat/ChatViewModel.kt | 125 ++++++++++-------- ...atSessionStore.kt => ConversationStore.kt} | 78 ++++++----- .../com/example/typeagentchat/MainActivity.kt | 21 +-- .../java/com/example/typeagentchat/Message.kt | 2 +- .../example/typeagentchat/WebSocketManager.kt | 92 +++++++++++-- .../app/src/main/res/xml/backup_rules.xml | 2 +- .../main/res/xml/data_extraction_rules.xml | 2 +- .../ConversationNotFoundErrorTest.kt | 36 +++++ ...rTest.kt => ConversationSerializerTest.kt} | 87 ++++++------ 9 files changed, 290 insertions(+), 155 deletions(-) rename android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/{ChatSessionStore.kt => ConversationStore.kt} (76%) create mode 100644 android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt rename android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/{ChatSessionSerializerTest.kt => ConversationSerializerTest.kt} (67%) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt index cdcc42f0b9..639b44904f 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt @@ -13,8 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -34,8 +32,8 @@ internal sealed interface ClientAction { } /** - * Owns the chat session so it survives configuration changes, and persists it - * so it also survives process death. + * Owns the chat conversation so it survives configuration changes, and + * persists it so it also survives process death. * * The [WebSocketManager] used to be a field on `MainActivity`, which meant a * theme change, rotation, font-scale change or locale change destroyed the @@ -46,15 +44,16 @@ internal sealed interface ClientAction { * * A ViewModel still dies with its process though, which is routine as soon as * the user switches to another app. The transcript is therefore mirrored to - * [ChatSessionStore] and restored on the next start. The server cannot supply - * that history - it exposes no history RPC - but it does keep handing back the - * same conversation, so a restored transcript still lines up with what the - * agent remembers. + * [ConversationStore] and restored on the next start, alongside the id of the + * conversation it belongs to. That id is passed back into + * [WebSocketManager.connect] so the client rejoins the exact same server-side + * conversation - the restored transcript then always lines up with what the + * agent remembers, with no post-hoc reconciliation needed. */ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val webSocketManager = WebSocketManager() - private val sessionStore = ChatSessionStore(application) + private val conversationStore = ConversationStore(application) val messages: StateFlow> = webSocketManager.messages val connectionStatus: StateFlow = webSocketManager.connectionStatus @@ -73,14 +72,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private var hasConnected = false - /** The conversation the restored transcript was recorded against. */ + /** + * The conversation the restored transcript was recorded against, and the + * one the next connect asks the server to resume. + */ @Volatile - private var restoredConversationId: String? = null + private var savedConversationId: String? = null /** * Completes once the persisted transcript has been read back and handed to * the socket. Connecting waits on it so a slow disk read can never race the - * first inbound message. + * first inbound message, and so the saved conversation id is known before + * the join is issued. */ private val restored = CompletableDeferred() @@ -99,20 +102,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } }) + webSocketManager.setStaleConversationHandler { + // The saved conversation no longer exists on the server, so the + // join fell back to the default one. The restored transcript is a + // record of a conversation nothing remembers - drop it rather than + // graft it onto a different one. + Log.w(TAG, "Saved conversation is gone; discarding the restored transcript") + savedConversationId = null + webSocketManager.clearMessages() + viewModelScope.launch { + withContext(Dispatchers.IO) { conversationStore.clear() } + } + } + viewModelScope.launch { - val session = withContext(Dispatchers.IO) { sessionStore.load() } - restoredConversationId = session.conversationId - webSocketManager.restoreMessages(session.messages) + val conversation = withContext(Dispatchers.IO) { conversationStore.load() } + savedConversationId = conversation.conversationId + webSocketManager.restoreMessages(conversation.messages) Log.d( TAG, - "Restored ${session.messages.size} messages " + - "for conversationId=${session.conversationId ?: "none"}" + "Restored ${conversation.messages.size} messages " + + "for conversationId=${conversation.conversationId ?: "none"}" ) restored.complete(Unit) } - observeSessionForPersistence() - reconcileRestoredTranscript() + observeConversationForPersistence() } /** @@ -128,16 +143,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * The initial value is dropped so the empty list the socket starts with * cannot overwrite a transcript that is still being read back. */ - private fun observeSessionForPersistence() { + private fun observeConversationForPersistence() { viewModelScope.launch { restored.await() webSocketManager.messages.drop(1).collectLatest { messages -> delay(SAVE_DEBOUNCE_MS) val conversationId = - webSocketManager.joinedConversationId.value ?: restoredConversationId + webSocketManager.lastJoinedConversationId.value ?: savedConversationId withContext(Dispatchers.IO) { - sessionStore.save( - PersistedChatSession( + conversationStore.save( + PersistedConversation( conversationId = conversationId, messages = messages ) @@ -148,27 +163,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * Drops a restored transcript that belongs to a conversation the server has - * since replaced, so the user is not left reading history the agent has no - * memory of. + * Connects on first use only, so Activity recreation does not restart the + * conversation. The saved conversation id is passed through so the server + * rejoins it directly. */ - private fun reconcileRestoredTranscript() { - viewModelScope.launch { - restored.await() - val previous = restoredConversationId ?: return@launch - val joined = webSocketManager.joinedConversationId.filterNotNull().first() - if (joined == previous) { - return@launch - } - Log.w( - TAG, - "Server conversation changed ($previous -> $joined); dropping stale transcript" - ) - webSocketManager.clearMessages() - } - } - - /** Connects on first use only, so Activity recreation does not restart the session. */ fun connectIfNeeded(url: String, tunnelToken: String?) { if (hasConnected) { return @@ -176,7 +174,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { hasConnected = true viewModelScope.launch { restored.await() - webSocketManager.connect(url = url, tunnelToken = tunnelToken) + webSocketManager.connect( + url = url, + tunnelToken = tunnelToken, + resumeConversationId = savedConversationId + ) } } @@ -184,7 +186,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { hasConnected = true viewModelScope.launch { restored.await() - webSocketManager.connect(url = url, tunnelToken = tunnelToken) + webSocketManager.connect( + url = url, + tunnelToken = tunnelToken, + resumeConversationId = webSocketManager.lastJoinedConversationId.value + ?: savedConversationId + ) } } @@ -223,16 +230,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun respondToPendingYesNo(yes: Boolean): Boolean = webSocketManager.respondToPendingYesNo(yes) /** - * Starts a new chat: clears the on-screen transcript and the copy on disk. + * Clears the chat history: the on-screen transcript and the copy on disk. * - * This is a client-side reset only. `joinConversation` keeps returning the - * same server-side conversation, so the agent's own memory is untouched - - * the server exposes no RPC to start a fresh one. + * This is a client-side reset only, matching `@clear` on the other + * TypeAgent canvases. The server-side conversation is untouched, so the + * agent keeps its own memory and the next connect resumes the same + * conversation. Starting a genuinely new server-side conversation would + * need `createConversation` / `leaveConversation`, which this client does + * not yet drive. */ - fun startNewChat() { + fun clearChatHistory() { webSocketManager.clearMessages() viewModelScope.launch { - withContext(Dispatchers.IO) { sessionStore.clear() } + withContext(Dispatchers.IO) { conversationStore.clear() } } } @@ -248,9 +258,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { override fun onCleared() { webSocketManager.setClientActionHandler(null) + webSocketManager.setStaleConversationHandler(null) webSocketManager.disconnect() clientActionEvents.close() - flushSessionToDisk() + flushConversationToDisk() super.onCleared() } @@ -267,14 +278,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * read would otherwise persist the still-empty transcript over the stored * one and lose the whole history. */ - private fun flushSessionToDisk() { + private fun flushConversationToDisk() { if (!restored.isCompleted) { return } - sessionStore.save( - PersistedChatSession( - conversationId = webSocketManager.joinedConversationId.value - ?: restoredConversationId, + conversationStore.save( + PersistedConversation( + conversationId = webSocketManager.lastJoinedConversationId.value + ?: savedConversationId, messages = webSocketManager.messages.value ) ) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ConversationStore.kt similarity index 76% rename from android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt rename to android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ConversationStore.kt index d7fb2b243b..9c0d388a63 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatSessionStore.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ConversationStore.kt @@ -9,29 +9,32 @@ import org.json.JSONObject /** * The chat transcript plus the server conversation it belongs to. * - * [conversationId] is stored so a restored transcript can be checked against the - * conversation the server actually hands back on the next join. If the server - * has moved on to a different conversation the old transcript is no longer a - * record of anything the agent remembers. + * [conversationId] is the conversation the transcript was recorded against. It + * is passed back to `joinConversation` on the next connect so the client + * resumes the same conversation rather than landing on the default one. + * + * Note this is a *conversation*, not a dispatcher *session*: AgentServer + * reserves "session" for dispatcher runtime state (configuration, caches, agent + * state), while a conversation is the user-facing identity and chat history. */ -data class PersistedChatSession( +data class PersistedConversation( val conversationId: String?, val messages: List ) { companion object { - val EMPTY = PersistedChatSession(conversationId = null, messages = emptyList()) + val EMPTY = PersistedConversation(conversationId = null, messages = emptyList()) } } /** - * A decoded session plus the number of stored messages that retention discarded - * (aged out, or trimmed by the message cap). + * A decoded conversation plus the number of stored messages that retention + * discarded (aged out, or trimmed by the message cap). * * A non-zero count means the file on disk still contains messages the app will * no longer show, so the caller should rewrite it to actually delete them. */ -data class DecodedChatSession( - val session: PersistedChatSession, +data class DecodedConversation( + val conversation: PersistedConversation, val droppedCount: Int ) @@ -42,35 +45,35 @@ data class DecodedChatSession( * A `ViewModel` only survives configuration changes, so without this the whole * conversation disappeared the first time Android reclaimed the backgrounded * app process. The TypeAgent server cannot fill the gap: it exposes no history - * API (`getChatHistory`, `getMessages` and friends all answer "No invoke - * handler"), so the client has to own its own transcript. + * API to this client (`getChatHistory`, `getMessages` and friends all answer + * "No invoke handler"), so the client has to own its own transcript. * * `SharedPreferences` is used rather than Room/DataStore to avoid adding * dependencies for what is a single small blob. Reads and writes are blocking, * so callers must keep them off the main thread. */ -class ChatSessionStore(context: Context) { +class ConversationStore(context: Context) { private val prefs: SharedPreferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - fun load(): PersistedChatSession { - val raw = prefs.getString(KEY_SESSION, null) ?: return PersistedChatSession.EMPTY + fun load(): PersistedConversation { + val raw = prefs.getString(KEY_CONVERSATION, null) ?: return PersistedConversation.EMPTY return try { - val decoded = ChatSessionSerializer.decodeDetailed(raw) + val decoded = ConversationSerializer.decodeDetailed(raw) if (decoded.droppedCount > 0) { // Retention filters on read, but the expired messages are still // sitting in the file until something else triggers a save. // Purge them now so "deleted" means deleted rather than hidden. Log.d(TAG, "Purging ${decoded.droppedCount} stale messages from disk") - save(decoded.session) + save(decoded.conversation) } - decoded.session + decoded.conversation } catch (error: Exception) { // A partially written or stale-format blob must not brick startup. - Log.e(TAG, "Discarding unreadable persisted chat session", error) + Log.e(TAG, "Discarding unreadable persisted conversation", error) clear() - PersistedChatSession.EMPTY + PersistedConversation.EMPTY } } @@ -80,41 +83,44 @@ class ChatSessionStore(context: Context) { * actually landed before the process is free to go away - `apply()` only * queues it. */ - fun save(session: PersistedChatSession) { + fun save(conversation: PersistedConversation) { try { prefs.edit() - .putString(KEY_SESSION, ChatSessionSerializer.encode(session)) + .putString(KEY_CONVERSATION, ConversationSerializer.encode(conversation)) .commit() } catch (error: Exception) { - Log.e(TAG, "Failed to persist chat session", error) + Log.e(TAG, "Failed to persist conversation", error) } } fun clear() { - prefs.edit().remove(KEY_SESSION).commit() + prefs.edit().remove(KEY_CONVERSATION).commit() } private companion object { - private const val TAG = "ChatSessionStore" + private const val TAG = "ConversationStore" /** * Backing file is `shared_prefs/$PREFS_NAME.xml`. That exact name is * excluded from backups in `res/xml/backup_rules.xml` and * `res/xml/data_extraction_rules.xml`, so renaming it here without * updating both silently starts uploading transcripts to the cloud. + * It also names the file already on users' devices, so a rename would + * orphan every stored transcript - hence the "session" wording here + * outliving the class rename. */ private const val PREFS_NAME = "typeagent_chat_session" - private const val KEY_SESSION = "session" + private const val KEY_CONVERSATION = "session" } } /** - * JSON encoding for [PersistedChatSession]. + * JSON encoding for [PersistedConversation]. * * Kept free of Android framework types so the round-trip is covered by plain * JVM unit tests. */ -object ChatSessionSerializer { +object ConversationSerializer { /** * Caps how much transcript is written back to disk. `SharedPreferences` @@ -134,15 +140,15 @@ object ChatSessionSerializer { private const val VERSION = 1 fun encode( - session: PersistedChatSession, + conversation: PersistedConversation, now: Long = System.currentTimeMillis() ): String { - val messages = retain(session.messages, now) + val messages = retain(conversation.messages, now) val array = JSONArray() messages.forEach { array.put(encodeMessage(it)) } return JSONObject() .put("version", VERSION) - .putOpt("conversationId", session.conversationId) + .putOpt("conversationId", conversation.conversationId) .put("messages", array) .toString() } @@ -150,7 +156,7 @@ object ChatSessionSerializer { fun decode( raw: String, now: Long = System.currentTimeMillis() - ): PersistedChatSession = decodeDetailed(raw, now).session + ): PersistedConversation = decodeDetailed(raw, now).conversation /** * Same as [decode], but also reports how many stored messages the retention @@ -161,10 +167,10 @@ object ChatSessionSerializer { fun decodeDetailed( raw: String, now: Long = System.currentTimeMillis() - ): DecodedChatSession { + ): DecodedConversation { val root = JSONObject(raw) if (root.optInt("version") != VERSION) { - return DecodedChatSession(PersistedChatSession.EMPTY, droppedCount = 0) + return DecodedConversation(PersistedConversation.EMPTY, droppedCount = 0) } val conversationId = root.optString("conversationId").takeIf { it.isNotBlank() } val array = root.optJSONArray("messages") ?: JSONArray() @@ -174,8 +180,8 @@ object ChatSessionSerializer { decodeMessage(item, now)?.let { messages += it } } val retained = retain(messages, now) - return DecodedChatSession( - session = PersistedChatSession( + return DecodedConversation( + conversation = PersistedConversation( conversationId = conversationId, messages = retained ), diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt index 39dc95b203..1725d46257 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt @@ -288,8 +288,8 @@ private fun ChatApp( verticalArrangement = Arrangement.spacedBy(12.dp) ) { ChatHeader( - canStartNewChat = messages.isNotEmpty(), - onNewChat = { viewModel.startNewChat() } + canClearChat = messages.isNotEmpty(), + onClearChat = { viewModel.clearChatHistory() } ) ConnectionStatusIndicator( status = connectionStatus, @@ -555,8 +555,8 @@ private fun ChatInputBar( @Composable private fun ChatHeader( - canStartNewChat: Boolean, - onNewChat: () -> Unit + canClearChat: Boolean, + onClearChat: () -> Unit ) { var showConfirmation by remember { mutableStateOf(false) } @@ -587,9 +587,9 @@ private fun ChatHeader( } TextButton( onClick = { showConfirmation = true }, - enabled = canStartNewChat + enabled = canClearChat ) { - Text("New chat") + Text("Clear chat") } } } @@ -599,21 +599,22 @@ private fun ChatHeader( if (showConfirmation) { AlertDialog( onDismissRequest = { showConfirmation = false }, - title = { Text("Start a new chat?") }, + title = { Text("Clear this chat?") }, text = { Text( "This deletes the messages on this device and cannot be undone. " + - "The agent keeps its own memory of the conversation." + "The conversation itself is not affected - the agent keeps its " + + "own memory of it." ) }, confirmButton = { TextButton( onClick = { showConfirmation = false - onNewChat() + onClearChat() } ) { - Text("Start new chat") + Text("Clear chat") } }, dismissButton = { diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt index 021fbb087a..9b839a99ff 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt @@ -10,7 +10,7 @@ data class Message( val isFinal: Boolean = false, /** * When the message was created, as epoch milliseconds. Persisted so stored - * transcripts can be aged out - see [ChatSessionSerializer.MAX_MESSAGE_AGE_MILLIS]. + * transcripts can be aged out - see [ConversationSerializer.MAX_MESSAGE_AGE_MILLIS]. */ val timestampMillis: Long = System.currentTimeMillis() ) { diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt index 5698d47fdd..57c427de67 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt @@ -31,6 +31,14 @@ class WebSocketManager { private var pendingUserInteraction: PendingUserInteraction? = null private var clientActionHandler: ClientActionHandler? = null + /** + * The conversation this connection asked to resume, if any. Kept separate + * from [conversationId] so an in-flight resume is never mistaken for a + * conversation that has actually been joined. + */ + private var requestedConversationId: String? = null + private var staleConversationHandler: (() -> Unit)? = null + private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages private val _pendingYesNoPrompt = MutableStateFlow(null) @@ -38,11 +46,13 @@ class WebSocketManager { /** * The conversation the server handed back on the last successful join. - * Exposed so the transcript can be reconciled with the server session it - * belongs to after the app process is recreated. + * + * Survives a disconnect deliberately: it is persisted alongside the + * transcript and passed back into the next [connect] so the client resumes + * the same conversation instead of landing on the server's default one. */ - private val _joinedConversationId = MutableStateFlow(null) - val joinedConversationId: StateFlow = _joinedConversationId + private val _lastJoinedConversationId = MutableStateFlow(null) + val lastJoinedConversationId: StateFlow = _lastJoinedConversationId private val _connectionStatus = MutableStateFlow( ConnectionStatus( @@ -58,6 +68,17 @@ class WebSocketManager { } } + /** + * Called when a resume was requested for a conversation the server no + * longer has. The client fell back to the default conversation, so the + * restored transcript belongs to nothing and should be discarded. + */ + internal fun setStaleConversationHandler(handler: (() -> Unit)?) { + synchronized(lock) { + staleConversationHandler = handler + } + } + /** * Seeds the transcript with messages recovered from disk. * @@ -78,8 +99,10 @@ class WebSocketManager { } /** - * Drops the local transcript. Used when the server reports a conversation - * the restored transcript does not belong to. + * Drops the local transcript. + * + * Used by the client-side "Clear chat" action, and when a resume lands on a + * conversation the restored transcript does not belong to. */ fun clearMessages() { synchronized(lock) { @@ -89,9 +112,18 @@ class WebSocketManager { } } + /** + * @param resumeConversationId the conversation to resume. When present it + * is passed straight to `joinConversation`, so the client rejoins the + * exact conversation it was last in. When the server no longer has it, + * the join falls back to the default conversation and the + * stale-conversation handler fires. When absent the server joins (or + * creates) the default conversation. + */ fun connect( url: String, - tunnelToken: String? = null + tunnelToken: String? = null, + resumeConversationId: String? = null ) { val targetUrl = url.trim() if (targetUrl.isBlank()) { @@ -109,6 +141,7 @@ class WebSocketManager { pendingUserInteraction = null conversationId = null connectionId = null + requestedConversationId = resumeConversationId?.takeIf { it.isNotBlank() } displayThreads.clear() displayMessageIds.clear() } @@ -131,7 +164,7 @@ class WebSocketManager { override fun onOpen(webSocket: WebSocket, response: Response) { if (connectionGeneration.get() != generation) return Log.d(TAG, "WebSocket connected") - joinConversation() + joinConversation(synchronized(lock) { requestedConversationId }) } override fun onMessage(webSocket: WebSocket, text: String) { @@ -298,10 +331,19 @@ class WebSocketManager { return true } - private fun joinConversation() { + /** + * Joins [resumeConversationId] when supplied, otherwise the server's + * default conversation (which it creates if none exists). + * + * If the requested conversation is gone the server answers + * "Conversation not found", and this retries once against the default. The + * retry passes `null`, so it cannot recurse. + */ + private fun joinConversation(resumeConversationId: String?) { val options = JSONObject() .put("clientType", "extension") .put("filter", false) + .putOpt("conversationId", resumeConversationId) sendInvoke( channelName = AGENT_SERVER_CHANNEL, @@ -324,8 +366,9 @@ class WebSocketManager { synchronized(lock) { conversationId = joinedConversationId connectionId = joinedConnectionId + requestedConversationId = null } - _joinedConversationId.value = joinedConversationId + _lastJoinedConversationId.value = joinedConversationId Log.d( TAG, @@ -338,6 +381,24 @@ class WebSocketManager { }, onError = { error -> Log.e(TAG, "joinConversation error: $error") + if (resumeConversationId != null && isConversationNotFoundError(error)) { + // The saved conversation is gone (server data wiped, + // deleted elsewhere). Fall back to the default conversation + // and tell the client its restored transcript is orphaned. + Log.w( + TAG, + "Conversation $resumeConversationId no longer exists; joining the default" + ) + // Read the handler under the lock but invoke it outside, so + // a client callback can never re-enter and deadlock. + val onStale = synchronized(lock) { + requestedConversationId = null + staleConversationHandler + } + onStale?.invoke() + joinConversation(null) + return@sendInvoke + } _connectionStatus.value = ConnectionStatus( text = "Error: $error", state = ConnectionStatus.State.ERROR @@ -1342,6 +1403,17 @@ class WebSocketManager { } } +/** + * Recognises the server's "Conversation not found" join failure so a resume of + * a conversation that no longer exists can fall back to the default one, + * instead of being surfaced as a connection error like a transport or auth + * failure would be. + * + * Mirrors `isConversationNotFoundError` in the agentServer TypeScript client. + */ +internal fun isConversationNotFoundError(error: String?): Boolean = + error?.trimStart()?.startsWith("Conversation not found", ignoreCase = true) == true + data class ConnectionStatus( val text: String, val state: State diff --git a/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml b/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml index 05dd356cf0..0f9133245c 100644 --- a/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml +++ b/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml @@ -9,7 +9,7 @@ The chat transcript is personal content and stays on the device it was written on. Without this it is uploaded to the user's Google account and restored onto any other device they sign in from. - Path must track ChatSessionStore.PREFS_NAME + ".xml". + Path must track ConversationStore.PREFS_NAME + ".xml". --> \ No newline at end of file diff --git a/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml b/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml index 13244dc77e..3038cb77aa 100644 --- a/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml @@ -9,7 +9,7 @@ The chat transcript is personal content and stays on the device it was written on. Without this it is uploaded to the user's Google account and restored onto any other device they sign in from. - Path must track ChatSessionStore.PREFS_NAME + ".xml". + Path must track ConversationStore.PREFS_NAME + ".xml". --> diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt new file mode 100644 index 0000000000..4c9e36a791 --- /dev/null +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt @@ -0,0 +1,36 @@ +package com.example.typeagentchat + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The resume flow only falls back to the default conversation for this one + * server error. Transport, auth and permission failures must keep surfacing as + * connection errors, otherwise a temporary outage would silently drop the user + * into a different conversation than the one they were reading. + */ +class ConversationNotFoundErrorTest { + + @Test + fun `the server's missing-conversation error is recognised`() { + assertTrue(isConversationNotFoundError("Conversation not found: abc-123")) + assertTrue(isConversationNotFoundError("Conversation not found")) + } + + @Test + fun `leading whitespace from RPC wrapping does not hide it`() { + assertTrue(isConversationNotFoundError(" Conversation not found: abc-123")) + } + + @Test + fun `other failures do not trigger the fallback`() { + assertFalse(isConversationNotFoundError("Disconnected")) + assertFalse(isConversationNotFoundError("Tunnel auth failed. Check token.")) + assertFalse(isConversationNotFoundError("WebSocket is not connected.")) + // Must not match on a mere mention of the phrase mid-message. + assertFalse(isConversationNotFoundError("Error: Conversation not found")) + assertFalse(isConversationNotFoundError("")) + assertFalse(isConversationNotFoundError(null)) + } +} diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationSerializerTest.kt similarity index 67% rename from android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt rename to android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationSerializerTest.kt index 9d693527f2..2a5facc935 100644 --- a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ChatSessionSerializerTest.kt +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationSerializerTest.kt @@ -5,16 +5,16 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -class ChatSessionSerializerTest { +class ConversationSerializerTest { - private fun session( + private fun conversation( conversationId: String? = "conversation-1", messages: List - ) = PersistedChatSession(conversationId = conversationId, messages = messages) + ) = PersistedConversation(conversationId = conversationId, messages = messages) @Test fun `round trip preserves the transcript`() { - val original = session( + val original = conversation( messages = listOf( Message(text = "what is on my list", isUser = true), Message( @@ -29,7 +29,7 @@ class ChatSessionSerializerTest { ) ) - val decoded = ChatSessionSerializer.decode(ChatSessionSerializer.encode(original)) + val decoded = ConversationSerializer.decode(ConversationSerializer.encode(original)) assertEquals("conversation-1", decoded.conversationId) assertEquals(2, decoded.messages.size) @@ -46,13 +46,13 @@ class ChatSessionSerializerTest { @Test fun `restored messages are always sealed`() { - val original = session( + val original = conversation( messages = listOf( Message(text = "streaming...", isUser = false, isFinal = false) ) ) - val decoded = ChatSessionSerializer.decode(ChatSessionSerializer.encode(original)) + val decoded = ConversationSerializer.decode(ConversationSerializer.encode(original)) // An unsealed restored bubble would render "Responding..." forever and // could be retro-targeted by WebSocketManager.finalizeAssistantMessage. @@ -61,27 +61,30 @@ class ChatSessionSerializerTest { @Test fun `only the most recent messages are persisted`() { - val messages = (1..ChatSessionSerializer.MAX_PERSISTED_MESSAGES + 25).map { + val messages = (1..ConversationSerializer.MAX_PERSISTED_MESSAGES + 25).map { Message(text = "message $it", isUser = true) } - val decoded = ChatSessionSerializer.decode( - ChatSessionSerializer.encode(session(messages = messages)) + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(conversation(messages = messages)) ) - assertEquals(ChatSessionSerializer.MAX_PERSISTED_MESSAGES, decoded.messages.size) + assertEquals(ConversationSerializer.MAX_PERSISTED_MESSAGES, decoded.messages.size) assertEquals("message 26", decoded.messages.first().text) assertEquals( - "message ${ChatSessionSerializer.MAX_PERSISTED_MESSAGES + 25}", + "message ${ConversationSerializer.MAX_PERSISTED_MESSAGES + 25}", decoded.messages.last().text ) } @Test fun `a missing conversation id round trips as null`() { - val decoded = ChatSessionSerializer.decode( - ChatSessionSerializer.encode( - session(conversationId = null, messages = listOf(Message(text = "hi", isUser = true))) + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode( + conversation( + conversationId = null, + messages = listOf(Message(text = "hi", isUser = true)) + ) ) ) @@ -91,7 +94,7 @@ class ChatSessionSerializerTest { @Test fun `an unknown payload version is ignored`() { - val decoded = ChatSessionSerializer.decode( + val decoded = ConversationSerializer.decode( """{"version":99,"conversationId":"c","messages":[{"id":"a","segments":[]}]}""" ) @@ -101,7 +104,7 @@ class ChatSessionSerializerTest { @Test fun `messages without usable segments are dropped`() { - val decoded = ChatSessionSerializer.decode( + val decoded = ConversationSerializer.decode( """{"version":1,"messages":[{"id":"a","isUser":true},{"id":"b","segments":[]}]}""" ) @@ -109,9 +112,9 @@ class ChatSessionSerializerTest { } @Test - fun `an empty session encodes and decodes cleanly`() { - val decoded = ChatSessionSerializer.decode( - ChatSessionSerializer.encode(PersistedChatSession.EMPTY) + fun `an empty conversation encodes and decodes cleanly`() { + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(PersistedConversation.EMPTY) ) assertNull(decoded.conversationId) @@ -129,8 +132,8 @@ class ChatSessionSerializerTest { Message(text = "now", isUser = true, timestampMillis = now) ) - val decoded = ChatSessionSerializer.decode( - ChatSessionSerializer.encode(session(messages = messages), now = now), + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(conversation(messages = messages), now = now), now = now ) @@ -141,14 +144,16 @@ class ChatSessionSerializerTest { fun `messages expire while the app is not running`() { val written = 1_800_000_000_000L val day = 24L * 60 * 60 * 1000 - val raw = ChatSessionSerializer.encode( - session(messages = listOf(Message(text = "hi", isUser = true, timestampMillis = written))), + val raw = ConversationSerializer.encode( + conversation( + messages = listOf(Message(text = "hi", isUser = true, timestampMillis = written)) + ), now = written ) // Same payload, read back long after it was written. - val fresh = ChatSessionSerializer.decode(raw, now = written + 5 * day) - val stale = ChatSessionSerializer.decode(raw, now = written + 45 * day) + val fresh = ConversationSerializer.decode(raw, now = written + 5 * day) + val stale = ConversationSerializer.decode(raw, now = written + 45 * day) assertEquals(1, fresh.messages.size) assertTrue(stale.messages.isEmpty()) @@ -157,9 +162,11 @@ class ChatSessionSerializerTest { @Test fun `timestamps survive a round trip`() { val stamp = 1_700_000_000_000L - val decoded = ChatSessionSerializer.decode( - ChatSessionSerializer.encode( - session(messages = listOf(Message(text = "hi", isUser = true, timestampMillis = stamp))), + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode( + conversation( + messages = listOf(Message(text = "hi", isUser = true, timestampMillis = stamp)) + ), now = stamp ), now = stamp @@ -178,7 +185,7 @@ class ChatSessionSerializerTest { ]} """.trimIndent() - val decoded = ChatSessionSerializer.decode(legacy, now = now) + val decoded = ConversationSerializer.decode(legacy, now = now) assertEquals("legacy", decoded.messages.single().text) assertEquals(now, decoded.messages.single().timestampMillis) @@ -191,8 +198,8 @@ class ChatSessionSerializerTest { // Message stamped in the future relative to `now`. val messages = listOf(Message(text = "future", isUser = true, timestampMillis = now + 10 * day)) - val decoded = ChatSessionSerializer.decode( - ChatSessionSerializer.encode(session(messages = messages), now = now), + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(conversation(messages = messages), now = now), now = now ) @@ -203,8 +210,8 @@ class ChatSessionSerializerTest { fun `a read that drops expired messages asks for the file to be rewritten`() { val written = 1_800_000_000_000L val day = 24L * 60 * 60 * 1000 - val raw = ChatSessionSerializer.encode( - session( + val raw = ConversationSerializer.encode( + conversation( messages = listOf( Message(text = "old", isUser = true, timestampMillis = written), Message(text = "new", isUser = true, timestampMillis = written + 40 * day) @@ -213,22 +220,24 @@ class ChatSessionSerializerTest { now = written ) - val decoded = ChatSessionSerializer.decodeDetailed(raw, now = written + 45 * day) + val decoded = ConversationSerializer.decodeDetailed(raw, now = written + 45 * day) // Without this signal the expired message stays in the file: filtering // on read only hides it from the UI. assertEquals(1, decoded.droppedCount) - assertEquals(listOf("new"), decoded.session.messages.map { it.text }) + assertEquals(listOf("new"), decoded.conversation.messages.map { it.text }) } @Test fun `a read with nothing to drop does not ask for a rewrite`() { val now = 1_800_000_000_000L - val raw = ChatSessionSerializer.encode( - session(messages = listOf(Message(text = "hi", isUser = true, timestampMillis = now))), + val raw = ConversationSerializer.encode( + conversation( + messages = listOf(Message(text = "hi", isUser = true, timestampMillis = now)) + ), now = now ) - assertEquals(0, ChatSessionSerializer.decodeDetailed(raw, now = now).droppedCount) + assertEquals(0, ConversationSerializer.decodeDetailed(raw, now = now).droppedCount) } } From 4433288d8c32ecc7420210f704ada5dec5c25b27 Mon Sep 17 00:00:00 2001 From: Jebran Syed Date: Wed, 12 Aug 2026 11:01:47 -0700 Subject: [PATCH 3/4] Wait briefly for RESUMED before dispatching a buffered client action lifecycleScope dispatches with Dispatchers.Main.immediate, so the clientActions collector starts running inline inside onCreate. An action buffered across a configuration change was therefore picked up while the new Activity was still CREATED, where launchExternalIntent's foreground guard refused it and told the agent the app was backgrounded - which was false, the app was in the foreground being recreated. Rotating the device with an alarm or timer in flight failed every time. Give the Activity a bounded grace period to reach RESUMED before dispatching. A genuinely backgrounded app still fails fast once the timeout elapses, so the agent's executeAction RPC is released promptly. Also answer the completion if the collector is cancelled while holding an action: it has already been taken off the channel, so no other Activity would ever see it and the RPC would hang. --- .../example/typeagentchat/ChatViewModel.kt | 5 +- .../com/example/typeagentchat/MainActivity.kt | 73 +++++++++++++++++-- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt index d5071ee0e1..f1d8450575 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt @@ -83,7 +83,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * * The consumer collects for the Activity's whole lifetime, not just while * it is resumed, so a queued action is never left waiting on the user - * returning to the app. Foreground-only enforcement lives in + * returning to the app. It does wait briefly for a recreated Activity to + * reach RESUMED before dispatching. Foreground-only enforcement lives in * `MainActivity.launchExternalIntent`, which reports the refusal. */ private val clientActionEvents = Channel(Channel.UNLIMITED) @@ -223,7 +224,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * Queues a client action for whichever Activity is currently resumed. + * Queues a client action for the chat Activity. * * The channel is unbounded, so the only way the send fails is if it has * already been closed in [onCleared]. When that happens the agent is still diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt index 7992ed75ea..d5488a3462 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt @@ -72,8 +72,11 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStateAtLeast import com.example.typeagentchat.ui.theme.TypeAgentChatTheme +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull class MainActivity : ComponentActivity() { @@ -101,16 +104,24 @@ class MainActivity : ComponentActivity() { // backgrounded - launchExternalIntent does the foreground check itself // and reports the refusal. Gating collection on RESUMED would instead // leave the action queued, and the server's call hanging, until the - // user happened to come back. The channel still buffers across the - // Activity gap during a configuration change. + // user happened to come back. lifecycleScope.launch { viewModel.clientActions.collect { action -> - when (action) { - is ClientAction.Alarm -> - launchSetAlarmIntent(action.action, action.completion) - is ClientAction.Timer -> - launchSetTimerIntent(action.action, action.completion) - is ClientAction.SearchNearby -> launchSearchNearbyIntent(action.action) + try { + awaitResumedOrGiveUp() + when (action) { + is ClientAction.Alarm -> + launchSetAlarmIntent(action.action, action.completion) + is ClientAction.Timer -> + launchSetTimerIntent(action.action, action.completion) + is ClientAction.SearchNearby -> launchSearchNearbyIntent(action.action) + } + } catch (cancellation: CancellationException) { + // The action was already taken off the channel, so no other + // Activity will ever see it. Answer the waiting RPC before + // unwinding rather than leaving the agent blocked. + action.failWith(ACTIVITY_GONE_MESSAGE) + throw cancellation } } } @@ -131,6 +142,41 @@ class MainActivity : ComponentActivity() { // in its onCleared. Disconnecting here would tear the connection down on // every rotation, theme or locale change. + /** + * Waits a short while for the Activity to reach RESUMED, giving up quietly + * if it does not. + * + * `lifecycleScope` dispatches with `Dispatchers.Main.immediate`, so the + * collector above starts running inline inside `onCreate` and an action + * buffered across a configuration change is picked up while the new + * Activity is still CREATED. Dispatching it right then would hit + * [launchExternalIntent]'s foreground guard and refuse a perfectly valid + * action, blaming a backgrounded app that is in fact mid-recreation. + * onResume follows within a frame or two, so a brief wait lets it through. + * + * The wait is bounded so a genuinely backgrounded app still fails fast and + * releases the agent's `executeAction` call instead of holding it until the + * user returns. + */ + private suspend fun awaitResumedOrGiveUp() { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + return + } + withTimeoutOrNull(RESUME_GRACE_MILLIS) { + lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) { } + } + } + + private fun ClientAction.failWith(message: String) { + val result = AndroidDeviceExecutionResult.Failure(message) + when (this) { + is ClientAction.Alarm -> completion(result) + is ClientAction.Timer -> completion(result) + // Nothing is waiting on this one, it is fire and forget. + is ClientAction.SearchNearby -> Unit + } + } + private fun launchSetAlarmIntent( action: SetAlarmAction, completion: (AndroidDeviceExecutionResult) -> Unit @@ -279,6 +325,17 @@ class MainActivity : ComponentActivity() { private companion object { private const val TAG = "MainActivity" + + /** + * How long a client action waits for the Activity to resume before it + * is treated as arriving while the app is backgrounded. Long enough to + * cover an Activity recreation, short enough that the agent is not left + * waiting on a user who has switched away. + */ + private const val RESUME_GRACE_MILLIS = 2_000L + + private const val ACTIVITY_GONE_MESSAGE = + "The Android app closed the chat screen before the action could run." } } From 09f4c6f0581fd9637bc1de415672d29daa1b9b5b Mon Sep 17 00:00:00 2001 From: Jebran Syed Date: Wed, 12 Aug 2026 16:33:41 -0700 Subject: [PATCH 4/4] Stop the conversation id being lost or written back stale Two windows where the persisted conversation id could go wrong: clearChatHistory removed the whole stored record, id included, and relied on the debounced writer to put the id back up to 400ms later. A force-stop in that window left nothing to resume, so the next launch silently landed in the default conversation - contradicting the documented promise that clearing is client-side only and the same conversation is resumed. It now writes an empty transcript that keeps the id. The not-found fallback cleared savedConversationId but left lastJoinedConversationId holding the deleted id until the fallback join landed. A debounced save or teardown flush in that window wrote the dead id back to disk, and a reconnect would try to resume it. The id is now dropped before the stale handler runs. The fallback's new id also only reached disk if the user happened to send a message afterwards, since the writer is driven by the message list. Persist it when the join lands instead. --- .../example/typeagentchat/ChatViewModel.kt | 66 +++++++++++++++++-- .../example/typeagentchat/WebSocketManager.kt | 6 ++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt index f1d8450575..436c128269 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -154,6 +155,49 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } observeConversationForPersistence() + observeConversationIdForPersistence() + } + + /** + * The conversation the transcript currently belongs to. + * + * Prefers the live join over the restored value, and is null only while no + * join has landed yet - including the gap between a missing conversation + * being detected and the fallback join completing, where the old id is + * deliberately no longer trusted. + */ + private fun currentConversationId(): String? = + webSocketManager.lastJoinedConversationId.value ?: savedConversationId + + /** + * Persists the conversation id as soon as a join lands. + * + * [observeConversationForPersistence] only writes when the message list + * changes, so a conversation that is joined but not yet spoken in never + * reaches disk. That matters most right after the not-found fallback: the + * stale handler has just wiped the stored record, and without this the new + * id would sit only in memory until the user happened to send something. + */ + private fun observeConversationIdForPersistence() { + viewModelScope.launch { + restored.await() + webSocketManager.lastJoinedConversationId + .filterNotNull() + .collect { conversationId -> + if (conversationId == savedConversationId) { + return@collect + } + savedConversationId = conversationId + withContext(Dispatchers.IO) { + conversationStore.save( + PersistedConversation( + conversationId = conversationId, + messages = webSocketManager.messages.value + ) + ) + } + } + } } /** @@ -174,8 +218,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { restored.await() webSocketManager.messages.drop(1).collectLatest { messages -> delay(SAVE_DEBOUNCE_MS) - val conversationId = - webSocketManager.lastJoinedConversationId.value ?: savedConversationId + val conversationId = currentConversationId() withContext(Dispatchers.IO) { conversationStore.save( PersistedConversation( @@ -288,11 +331,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * conversation. Starting a genuinely new server-side conversation would * need `createConversation` / `leaveConversation`, which this client does * not yet drive. + * + * An empty record is written rather than the whole entry removed, because + * removing it would drop the conversation id too. The debounced writer + * would put it back a moment later, but a force-stop in that window would + * leave nothing to resume and the next launch would silently land in the + * default conversation - breaking the promise above. */ fun clearChatHistory() { webSocketManager.clearMessages() + val conversationId = currentConversationId() viewModelScope.launch { - withContext(Dispatchers.IO) { conversationStore.clear() } + withContext(Dispatchers.IO) { + conversationStore.save( + PersistedConversation( + conversationId = conversationId, + messages = emptyList() + ) + ) + } } } @@ -334,8 +391,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } conversationStore.save( PersistedConversation( - conversationId = webSocketManager.lastJoinedConversationId.value - ?: savedConversationId, + conversationId = currentConversationId(), messages = webSocketManager.messages.value ) ) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt index c7aefa608b..b06be8b486 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt @@ -414,6 +414,12 @@ class WebSocketManager { requestedConversationId = null staleConversationHandler } + // Drop the dead id before handing control to the client. + // It is still the "last joined" one, so a debounced save or + // a teardown flush landing while the fallback join is in + // flight would write the deleted conversation back to disk, + // and a reconnect in that window would try to resume it. + _lastJoinedConversationId.value = null onStale?.invoke() joinConversation(null) return@sendInvoke