A conversation in the Assistant is a row in copilotChats with type: 'assistant_sandbox'. Each user turn appends a message; each assistant turn appends a message after the sandbox run finishes. The same row holds the full thread plus any per-turn file attachments.
Storage shape
copilotChats carries a messages JSON column — an ordered array of PersistedMessage entries. The schema for a single message lives in lib/assistant-sandbox/persisted-message.ts:
| Field | Notes |
|---|---|
id | Stable message id. The first user message id is also stored as conversationId and used to lock the chat. |
role | 'user' | 'assistant'. |
content | A list of PersistedContentBlock entries (text, step trace, structured tool output). |
fileAttachments | PersistedFileAttachment[] — chat-bag references the user attached to a user message. |
createdAt | Unix epoch ms. |
The full step trace from the sandbox NDJSON stream is folded into the assistant message's content blocks at persist time so a history reload renders the same UI as the live stream did — no extra fetch.
First-turn creation
The first user message in a conversation creates the chat row (no chat id passed in the request). appendUserMessage:
- Generates a chat id and a user message id.
- Inserts a row into
copilotChatswithtype: 'assistant_sandbox',workspaceId, optionalworkflowId, and the user's first message. - Sets
conversationId = userMessageId— this is the per-conversation lock, used to prevent concurrent in-flight turns from racing on the same chat.
Subsequent turns reuse the existing chat id and append to messages.
Concurrency lock
The conversationId on a chat row doubles as a single-flight lock. While a turn is being streamed, the lock is held against (chatId, conversationId); concurrent dispatches on the same chat are rejected. When the stream settles (success or failure), clearConversationLock releases it.
If a client disconnects mid-stream and the route's finally block runs cleanly, the lock is released and the user can dispatch the next turn. If the process dies hard, a stale lock can remain — see the lock-clearing helpers in lib/assistant-sandbox/persistence.ts.
Replay
When the Assistant streams a new turn, the route loads the most recent 20 turns (REPLAY_TURN_LIMIT) from the chat and forwards them as the conversation field on the envelope:
interface ChatTurn {
role: 'user' | 'assistant'
content: string
ts: number // Unix epoch ms
}Older turns are not sent. This is by design — sandbox cost scales with replay length, and the 20-turn window has been enough in practice. If you need long-horizon memory, attach the relevant content as a file or save it to Tables.
File attachments persist on the user message
When a user attaches files to a turn, the route resolves the attachmentIds against assistantChatBagFiles and writes the resulting fileAttachments into the persisted user message. A history reload renders the file chip rail directly from the message — no extra round-trip to the bag table. See Sandbox › Assets for the bag itself.
Conversations are not automatically deletable on workspace cleanup. Soft-delete the chat (deletedAt on copilotChats) and the chat-bag files cascade via softDeleteChatBagFile.
Source
apps/actana/lib/assistant-sandbox/persistence.ts—appendUserMessage,appendAssistantMessage,loadRecentTurns, lock helpersapps/actana/lib/assistant-sandbox/persisted-message.ts—PersistedMessageshapeapps/actana/app/workspace/[workspaceId]/assistant/components/hydrate-persisted.ts— client hydration of stored historypackages/db/schema.ts—copilotChats,assistantChatBagFiles