Skip to content

Preserve chat state - #2836

Open
jebrans wants to merge 5 commits into
microsoft:mainfrom
jebrans:dev/jebransyed/android-demo
Open

Preserve chat state#2836
jebrans wants to merge 5 commits into
microsoft:mainfrom
jebrans:dev/jebransyed/android-demo

Conversation

@jebrans

@jebrans jebrans commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

The mobile-2 Android sample kept its connection and transcript in MainActivity. Activity recreation—such as rotation, theme, font-scale, or locale changes—therefore disconnected the WebSocket and discarded the visible chat. The transcript was also lost whenever Android reclaimed the app process.

The Android client does not currently load AgentServer display history, so it needs a local conversation cache to restore the UI immediately after process death.

Changes

  • Move connection and chat ownership into ChatViewModel so configuration changes preserve the WebSocket, messages, input state, and pending interactions.
  • Add ConversationStore, backed by SharedPreferences, to persist:
    • the visible transcript;
    • the last joined AgentServer conversation ID.
  • Resume the saved conversation directly by passing its ID to joinConversation during reconnect.
  • If the saved conversation no longer exists, discard its stale local transcript and fall back to the server’s default conversation.
  • Align Android naming with AgentServer terminology: conversation represents user-facing identity and chat history, while session is reserved for dispatcher runtime state.
  • Replace the misleading New chat action with Clear chat. This clears only the local display history; the server-side conversation and agent memory are unchanged.
  • Seal restored messages as final so they cannot remain stuck in a streaming state or be modified by later display updates.
  • Debounce persistence writes during streaming and synchronously flush the final state when the ViewModel is cleared.
  • Limit persisted data to the newest 200 messages and remove messages older than 30 days on both save and load.
  • Exclude the persisted transcript from Android Auto Backup and device-to-device transfer.
  • Buffer agent-driven Android actions across Activity recreation and briefly wait for the recreated Activity to reach RESUMED before dispatching foreground intents.
  • Automatically send final voice-recognition results.

Testing

  • Added JVM tests for conversation serialization, retention, message limits, legacy payloads, clock changes, and missing-conversation error detection.
  • Verified transcript restoration after configuration changes, background process reclamation, and force-stop.
  • Verified expired messages are removed from both the UI and persisted storage.
  • Verified Clear chat removes the local transcript without changing the AgentServer conversation.
  • Verified end-to-end behavior against a live AgentServer through a Dev Tunnel.

Follow-up

A later PR can treat ConversationStore as an immediate/offline cache and use getDisplayHistory(afterSeq?) to backfill updates produced by other clients sharing the same conversation.

… 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>

@GeorgeNgMsft George Ng (GeorgeNgMsft) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to restate the current layering that I see : 
ChatViewModel coordinates lifecycle + UX, which should look similar to the CLI/Shell reference flows
WebSocketManager is the Android agent-server RPC client

It would be worth matching the persistence semantics in AgentServer's existing canvases now (the additional functionality can be built out later on)

I think this should include : 

  • "New conversation/chat" flow alignment with existing flows
  • "Conversation" naming alignment for better semantic representation
  • Updating the conversation resume behavior (for efficiency)

Overall, I think this is great! The conversation/session naming gets a bit confusing so I just wanted to help draw the distinctions early.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you've added joinedConversationId, since it persists after a disconnect, maybe it should be renamed as lastJoinedConversationId or recentConversationId.

I think you can just pass the id back into the connection as part of the connection options for a natural reconnect, and if it doesn't exist, then you can just handle the fallback logic. If the conversation no longer exist, it should create a new conversation as the fallback and clear then.

That way you don't need to unnecessarily load history and reconcile it -- matching the existing AgentServer conventions.

This may also be helpful reference https://github.com/microsoft/TypeAgent/blob/main/ts/docs/architecture/agents/agentServerConversations.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to lastJoinedConversationId. The saved id is now passed into connect(resumeConversationId = …) and forwarded as joinConversation's conversationId connect option, so the client rejoins the exact conversation the transcript belongs to. When it's null the option is omitted entirely, so a first run still auto-joins the default.

@@ -0,0 +1,257 @@
package com.example.typeagentchat

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On AgentServer, the chats are stored as ConversationRecord objects and we persist them on disk in a conversations.json file. We should try to preserve the 'conversation' terminology here so that we match AgentServer.

The reason is that 'sessions' have been reserved for Dispatcher's runtime state which also gets persisted onto disk with configurations, cache setups, etc. This is a legacy functionality that I don't personally think is justified anymore (and it adds so much confusion), but cleaning it up should be a separate discussion.

Conversation : User-facing identity & chat history
Session : DIspatcher configurations, caches, agent states

So we should probably rename the classes, files, etc. since these involve the user conversations rather than dispatcher sessions.

@GeorgeNgMsft George Ng (GeorgeNgMsft) Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[For a later PR] Currently, it seems like ChatSessionStore as the authoritative storage. However, a big capability we want to continue supporting is the ability to have real time-shared conversations across clients. Right now, multiple clients are able to connect to a conversation and load the full conversation history by calling the getDisplayHistory rpc then get updates broadcasted to them.

Currently Here:

Load local messages
→ join an implicit default conversation
→ compare IDs
→ receive only future live events
→ overwrite local snapshot

This currently supports live display events from other clients, but there is potential for a display history gap since the Android client doesn't read the display history. I propose that we treat the ChatSessionStore as a cache for immediate/offline display and display log cursor.

Proposed :

Load local cache + saved conversation ID + sequence cursor
→ join that exact conversation
→ fetch missing server display-history entries via getDisplayHistory(afterSeq?)
→ reconcile/rebuild the cache
→ receive future live events

This will let you show the display history immediately while also backfilling any chat history from other clients on the same conversations.


This can be done later on and discussed further if you'd like. The current flow is fine for this PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed. Will add a follow up for ChatSessionStore

* the server exposes no RPC to start a fresh one.
*/
fun startNewChat() {
webSocketManager.clearMessages()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename this to "clearChatHistory" or "clearChat", I think start newChat is a bit misleading here.

On the other TypeAgent canvases, Clear (@clear) usually has some client-side implementation for clearing the display history without touching the memory or conversation (UX only). Reconnecting will reload the history though (not sure if clearing the on disk memory is a good idea).

New chat will typically follow this flow :
createConversation
→ joinConversation(new ID)
→ rebind and persist new ID
→ leaveConversation(old ID)
→ clear/replay UI

CLI Implementation

async function handleNewWithConfirm(

AgentServer client reference

export async function switchConversationSafe(

@jebrans jebrans Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to clearChatHistory() This will clear local cache and not Typeagent's data.

jebrans and others added 4 commits August 12, 2026 10:11
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>
Integrates upstream microsoft#2845 (client-hosted Android agent) with the
conversation-persistence work in this PR. Resolution notes:

- WebSocketManager.connect() now takes both schemaContent (upstream) and
  resumeConversationId (this PR); the connect-time synchronized block
  seeds requestedConversationId, agentSchemaContent and resets
  isClientAgentRegistered. The "Conversation not found" fallback rejoin
  reuses the same onResult path, so registerClientAgent still runs after
  falling back to the default conversation.

- MainActivity collects clientActionEvents in a plain lifecycleScope
  launch rather than repeatOnLifecycle(RESUMED). Upstream's executeAction
  holds a server RPC open until the completion callback fires, so gating
  on RESUMED would hang that RPC while backgrounded. launchExternalIntent
  already does its own RESUMED check and fails fast, matching upstream's
  behavior; the unbounded channel still buffers across configuration
  changes.

- ClientAction.Alarm/.Timer carry upstream's completion callback.
  dispatchClientAction fails the completion when the channel is closed so
  the RPC can never leak.

- Upstream's onDestroy teardown is intentionally not carried over: the
  socket is owned by the ViewModel and tearing it down in onDestroy would
  disconnect on every rotation.

- README keeps both the renamed "Client-hosted Android agent" section and
  the rewritten "Conversation persistence" section.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants