Actana

Conversations

How Assistant conversations are stored, replayed, and locked.

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:

FieldNotes
idStable message id. The first user message id is also stored as conversationId and used to lock the chat.
role'user' | 'assistant'.
contentA list of PersistedContentBlock entries (text, step trace, structured tool output).
fileAttachmentsPersistedFileAttachment[] — chat-bag references the user attached to a user message.
createdAtUnix 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:

  1. Generates a chat id and a user message id.
  2. Inserts a row into copilotChats with type: 'assistant_sandbox', workspaceId, optional workflowId, and the user's first message.
  3. 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.tsappendUserMessage, appendAssistantMessage, loadRecentTurns, lock helpers
  • apps/actana/lib/assistant-sandbox/persisted-message.tsPersistedMessage shape
  • apps/actana/app/workspace/[workspaceId]/assistant/components/hydrate-persisted.ts — client hydration of stored history
  • packages/db/schema.tscopilotChats, assistantChatBagFiles

On this page