Chat Persistence & State
The ChatStore seam, auto-persist via the chat call option, the pure chat state engine (applyUIPart and the ordered parts projection), and branching for regenerate / edit-and-resend.
El contenido de las páginas de documentación está en inglés. La navegación, la búsqueda y la interfaz siguen el idioma elegido.
@deuz-sdk/core/chat is the framework-agnostic chat layer (1.7, extended in 1.9): a two-method ChatStore persistence seam the agentic loops auto-save into, plus a pure chat state engine — the render-friendly UIMessage shape, the reducer that folds Deuz UI wire parts into it, the ordered parts projection, canonical-history reconstruction in both directions, and the branch helpers behind regenerate / edit-and-resend. @deuz-sdk/react's hooks bind this module to React state — no business logic lives there.
Everything in @deuz-sdk/core/chat is edge-safe pure functions over immutable data. The file-backed reference store ships separately at @deuz-sdk/core/chat/node; the request validator that guards a chat route (validateChatRequest) is re-exported from this same subpath.
ChatStore — the persistence seam
The SessionStore pattern again: implement two methods against any backend — a Supabase table, Redis, the filesystem, or the in-memory reference store.
interface ChatRecord {
chatId: string;
/** Ownership/tenancy — REQUIRED, aligned with the memory scope model. */
scope: MemoryScope;
/** Full immutable history (the loops never mutate prior arrays). */
messages: Message[];
/** Branch lineage (edit-and-resend can fork a chat; optional). */
parentId?: string;
/** `deps.clock.now()` at save time. */
updatedAt: number;
}
interface ChatStore {
saveChat(record: ChatRecord): void | Promise<void>;
loadChat(chatId: string): ChatRecord | undefined | Promise<ChatRecord | undefined>;
/** Optional cleanup (the loops never call it). */
deleteChat?(chatId: string): void | Promise<void>;
/** Optional enumeration for pickers/tooling. */
listChats?(scope?: MemoryScope): string[] | Promise<string[]>;
}Scope is mandatory and reuses MemoryScope — which gained a chatId field in 1.7 precisely so chat records, memory records, and durable runs share one ownership model. listChats(scope) filters by exact match on the scope fields you provide.
Auto-persist: the chat call option
Set chat on any generateText / streamChat call and the loop persists the full immutable history into the store at terminal boundaries — completion, suspension (approval break), and mid-stream error (completed turns up to the failure still persist):
interface ChatPersistOptions {
store: ChatStore;
chatId: string;
scope: MemoryScope; // mandatory ownership
parentId?: string; // fork lineage recorded on the saved record
}import { streamChat } from '@deuz-sdk/core';
import { createInMemoryChatStore } from '@deuz-sdk/core/chat';
const store = createInMemoryChatStore();
const result = streamChat({
model,
messages,
tools,
maxSteps: 8,
chat: { store, chatId: 'chat-42', scope: { userId: 'u_1' } },
});Two contracts to know:
- Best-effort by design. A throwing
saveChatlogs viadeps.logger.errorand never kills the run (theSessionStorerule). - Tool-less calls route through the loop. Setting
chat(ormemory) routes even a call withouttoolsthrough the agentic loop, so every chat shape persists at the same terminal boundaries. The visible consequence:step-start/step-finishparts appear on the stream for calls that previously had none — keep thedefaultcase in your part switch.
There is no diff machinery to configure: history is immutable by contract, so each save is a superset of the last and saveChat simply receives the full array.
Reference stores
For production, use a store pack
The two stores below are references: a Map and one JSON file per chat. 2.0 ships real backends — createSqliteStores, createRedisStores and createPostgresStores — each returning a ChatStore alongside the matching SessionStore / MemoryStore on the same connection, with the binary-safe record codec already wired.
import { createPostgresStores } from '@deuz-sdk/core/stores/postgres';
const stores = createPostgresStores({ connectionString: process.env.DATABASE_URL! });
chat: { store: stores.chats, chatId, scope: { userId } }The hand-rolled Supabase adapter further down still works and is still the template for any backend the packs do not cover.
createInMemoryChatStore (edge-safe)
A Map keyed by chatId; saveChat copies the message array. Implements all four methods. Single runtime only — dev, tests, one long-lived server.
createJsonlChatStore (Node)
@deuz-sdk/core/chat/node — one JSON file per chat under dir (created on first save), zero dependencies, lazy node:fs/promises. Writes are atomic-enough on one filesystem: the record is written to a .tmp file, then renamed over the target — a crash mid-write can never tear an existing chat. Binary message parts survive via the $deuzBytes codec (below). loadChat returns undefined for missing/corrupt files rather than throwing.
import { createJsonlChatStore } from '@deuz-sdk/core/chat/node';
const store = createJsonlChatStore({ dir: './data/chats' });The serializeChatRecord codec
For persistent backends with text/JSON columns, serialize with the bundled codec — plain JSON.stringify would decay binary parts (raw Uint8Array images) into index-keyed objects the adapters cannot send back. It uses the same { "$deuzBytes": "<base64>" } tag as the durable checkpoint codec; on load, only the codec's own exact shape converts back to bytes — payloads that merely look like the tag stay plain data.
import { serializeChatRecord, deserializeChatRecord } from '@deuz-sdk/core/chat';
const row = serializeChatRecord(record); // JSON string, Uint8Array-safe
const back = deserializeChatRecord(row); // real Uint8Arrays restoredSupabase table adapter
create table chats (
chat_id text primary key,
scope jsonb not null,
record text not null, -- serializeChatRecord output
updated_at bigint not null
);import type { ChatStore } from '@deuz-sdk/core/chat';
import { serializeChatRecord, deserializeChatRecord } from '@deuz-sdk/core/chat';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
export const chatStore: ChatStore = {
async saveChat(record) {
const { error } = await supabase.from('chats').upsert({
chat_id: record.chatId,
scope: record.scope, // queryable copy for listChats-style filters
record: serializeChatRecord(record), // binary-safe source of truth
updated_at: record.updatedAt,
});
if (error) throw error; // logged by the loop, never kills the run
},
async loadChat(chatId) {
const { data } = await supabase.from('chats').select('record').eq('chat_id', chatId).single();
return data ? deserializeChatRecord(data.record) : undefined;
},
};The pure chat engine
The engine is the part a UI binding needs regardless of storage. All functions are pure and total: new objects out, inputs untouched, unknown wire parts ignored (the open-union rule), errors recorded rather than thrown.
UIMessage and the per-turn reducer
interface UIToolCall {
toolCallId: string;
toolName: string;
input: unknown;
output?: unknown;
isError?: boolean;
state: 'call' | 'result' | 'approval-requested';
/** Fine-grained lifecycle from `tool-state` parts (1.7 additive). */
runState?: ToolRunState;
}
interface UIMessage {
id: string;
role: 'user' | 'assistant';
content: string;
reasoning?: string;
toolCalls?: UIToolCall[];
/** Ordered projection (1.9, additive) — absent until the turn has its first element. */
parts?: UIMessagePart[];
}One streamed assistant turn accumulates into an AssistantTurnState: the UIMessage plus approvals (gated calls awaiting verdicts), serverResults (which toolCallIds the server already executed — the rest are client tools), and the 1.7 wire additions: costUsd / cacheSavingsUsd (live cost parts), budgetExceeded, dataParts (app-defined data-{name} parts in arrival order), citations, and error (the redacted server error message, recorded — not thrown). 1.8 added plan (the latest plan-update snapshot) and activity; 1.9 added the terminal readouts usage / finishReason (from the wire's finish part), steps (per-step usage and finish reason, in arrival order) and verifications (verifyStep verdicts). All four stay absent until the matching part arrives — as do plan, costUsd and budgetExceeded before them, because createAssistantTurn's initial shape is public surface and never invents a key.
applyUIPart(turn, part) folds one wire part into the turn and returns a new state:
import { createAssistantTurn, applyUIPart } from '@deuz-sdk/core/chat';
import { connectDeuzStream } from '@deuz-sdk/core/ui';
let turn = createAssistantTurn(crypto.randomUUID());
for await (const part of connectDeuzStream(resumeUrl)) {
turn = applyUIPart(turn, part); // pure — hand it straight to setState
render(turn);
}Reducer details worth knowing:
- A
tool-state: input-streamingpart opens a placeholder tool call before the assembledtool-callpart arrives, so the lifecycle is visible from the first fragment (the latertool-callcompletes it in place, no duplicates);startadopts the server'smessageId;tool-resultmoves the call tostate: 'result'and records it inserverResults. - Addressable data parts (1.9). A
data-{name}part carrying anidreplaces the earlier entry with the same name and id in place — indataPartsand in the orderedparts— instead of appending, so a live status widget stays one entry rather than three. A write without an id appends, exactly as in 1.7/1.8. Only a string id addresses an entry; anything else reads as absent and keeps the append-only path, because the value crossed a network boundary. SeewriteDatafor the server side. - Denial fields (1.9).
tool-statecarries optionaldenied/deniedReason, which the reducer folds ontoUIToolCallso a declined call stops rendering as "getWeather failed". The streaming loop'stool-stateemitter sets them at both of its terminal sites, so every refusal the built-in approval flow settles arrives marked: a server-modeapproveToolCallreturningfalse(denied: true, no reason — the hook's verdict is a bare boolean), anapprovalResponsesverdict (its ownreason, verbatim), a gated call left unanswered, an unanswered client tool, and a tokenapprovalSignerrejected. A tool that threw gains no denial fields, which is the distinction the fields exist for. - Three more channels (1.9).
warningparts fold intoturn.warnings(CallWarning[], the same payloadStreamChatResult.warningsresolves),false-finishparts intoturn.falseFinishes, andsub-agentparts intoturn.subAgents(below). All three are optional and absent until the first entry arrives —createAssistantTurn's literal is public surface and stays unchanged.
Sub-agent frames (1.9)
applyUIPart folds a sub-agent part into its own channel, one frame per agentPath:
turn.subAgents?.[0];
// { agentPath: ['researcher'], afterPart: 3, turn: AssistantTurnState }turn is a full AssistantTurnState folded by this reducer re-entering itself, so the child's text, reasoning, ordered parts, tool cards, citations and activity are all as complete as the parent's — one implementation, not a parallel one. afterPart is the count of the parent's ordered elements when the frame opened (normally just after the tool card for the delegating call), so a renderer splices the block back in at the handoff point. A 2nd-level sub-agent is a sibling frame with a two-segment path, because the wire is single-wrapped.
It is deliberately not folded into the parent's buckets or its ordered parts: that would attribute the child's prose to the main agent, and it would put the child's tool_use into assistantMessageFromTurn's output with no matching tool_result — the payload that 400s the next request. Frames are sealed alongside the parent at finish, error and sealAssistantTurn. useChat exposes them as subAgents — see React hooks.
Ordered parts (1.9)
content / reasoning / toolCalls are buckets. A multi-step turn — think → search → "I found 3 papers" → fetch → "here is the summary" — flattens into one reasoning blob, one text blob and a detached list of tool cards, so a renderer cannot place a tool card between the two sentences it belongs between.
Since 1.9 the reducer also records arrival order in UIMessage.parts. No wire change was needed: the canonical stream is strictly ordered and toDeuzStreamResponse preserves that order, so arrival order is the interleave. The buckets are not deprecated and keep their exact 1.8 semantics — content is still the turn's full text, byte-identical, and the same part.text lands in both views, so the two can never disagree.
type UIMessagePart =
| { type: 'text'; text: string; state: 'streaming' | 'done' }
| {
type: 'reasoning';
text: string;
signature?: string;
encrypted?: boolean; // opaque provider payload — do NOT render
redacted?: boolean;
state: 'streaming' | 'done';
}
| { type: 'tool'; toolCallId: string } // a REFERENCE into UIMessage.toolCalls
| { type: 'data'; name: string; id?: string; payload: unknown }
| Extract<DeuzUIPart, { type: 'citation' }> // the wire object verbatim
| { type: 'step-start'; step: number }
| { type: 'file'; mediaType: string; data: string | Uint8Array; url?: string };A framework-agnostic walk — @deuz-sdk/react exposes the same array, see rendering ordered parts for the JSX version:
// No `parts` (pre-1.9, hand-built, or restored from old storage)? Render the buckets.
for (const part of message.parts ?? []) {
switch (part.type) {
case 'step-start':
// A real streamed turn's parts[0] is normally one of these.
drawStepDivider(part.step);
break;
case 'text':
drawProse(part.text, part.state === 'streaming');
break;
case 'reasoning':
// `encrypted` text is an opaque provider payload, not thinking text.
if (!part.encrypted) drawThinking(part.text);
break;
case 'tool': {
const call = message.toolCalls?.find((c) => c.toolCallId === part.toolCallId);
if (call) drawToolCard(call);
break;
}
case 'file':
drawAttachment(part.mediaType, part.data, part.url);
break;
default:
break; // open union — always keep a default
}
}What the union encodes, in the order people get it wrong:
partsis optional and absent until the first element exists.createAssistantTurn's shape is public surface, so the key is never invented; renderpartswhen present, the buckets otherwise.step-startis normallyparts[0]of a real streamed turn, because the streaming loop pushes one at the top of every iteration. A renderer without a case for it hits itsdefaulton the very first element.- Skip
reasoningparts flaggedencrypted— the text is an opaque encrypted provider payload (OpenAI Responses), not display text;redactedmarks a provider-redacted thinking block. Neither flag can appear on a streamed turn (the wire'sreasoning-deltacarries no such field) — they arrive on history projected byuiFromMessages. - A text/reasoning element is
'streaming'only while it is the newest thing in the turn. Opening anything else seals it, which is what makes a text delta arriving after a tool call a new paragraph instead of silently re-opening the one the tool interrupted. - A
toolelement is a reference, not a copy — a call's state (input → approval → result → denial) changes over the turn's life, and two copies of it would drift. - A
fileelement carries the canonicalImagePart.imagevalue verbatim (bytes stay bytes).urlis set only when the value is already a renderabledata:/http(s):src; for bytes, build one yourself. See Files & PDFs.
sealAssistantTurn
applyUIPart seals the tail on the wire's terminal parts (finish, error). sealAssistantTurn(turn) covers the boundaries only the binding knows about — a user abort, a reader that walked away, a turn restored from storage:
import { sealAssistantTurn } from '@deuz-sdk/core/chat';
turn = sealAssistantTurn(turn); // every 'streaming' text/reasoning element becomes 'done'It is idempotent and returns the same object when there is nothing to seal (unlike applyUIPart, which always returns a new one), so a React binding can call it unconditionally without forcing a re-render. A stream that dies with neither finish nor error and is never sealed deliberately leaves the tail 'streaming' — that is the truth about a truncated turn.
Back to canonical history
assistantMessageFromTurn(turn)— the canonical assistantMessagefor the request history: text plustool_useparts, exactly as the wire streamed them (the client-tools reconstruction).clientToolResultMessage(results)— the canonicalrole: 'tool'message for client-executed tool results.uiFromMessages(messages, generateId)— the reverse projection: a canonical history (e.g. a chat loaded from aChatStore) into render-friendlyUIMessages.toolmessages merge their results into the preceding assistant turn;systemmessages are not rendered;generateIdsupplies stable ids (injectdeps.generateId, or scripted ids in tests). Since 1.9 each message also carries the orderedpartsprojection, in canonical array order — which is what makes attachments visible instead of rendering as an empty bubble.canonicalFromUI(ui)— the inverse ofuiFromMessages(1.9), for a binding that lets the app replace history (setMessages): the two views must stay coherent, and only the canonical one is POSTable.
import { uiFromMessages } from '@deuz-sdk/core/chat';
const record = await store.loadChat('chat-42');
const ui = record ? uiFromMessages(record.messages, () => crypto.randomUUID()) : [];canonicalFromUI prefers the ordered parts over the buckets, and it is lossy on purpose — where it loses matters:
- Survives (with
partspresent): the interleave,ImagePartattachments with their media type (carrier verbatim), reasoningsignature/encrypted/redacted,tool_use.providerMetadata(Gemini'sthoughtSignature— a dropped one 400s the next request), and executed tool calls re-emitted as therole: 'tool'message that follows the turn, because everytool_usemust get atool_result. - Does not survive:
systemmessages (uiFromMessagesnever rendered them, so nothing here can bring them back — re-prepend your own), message-levelproviderMetadata, consecutive text parts (they merge into one block, which is what makes a text-only history round-trip exactly), UI-only bookkeeping (runState, denial, pending approvals,data-*parts, citations, step boundaries), and a call still awaiting its result, which stays a baretool_use. - Without
parts— a hand-built turn, or one restored from a pre-1.9 store — it collapses to bucket order (one reasoning block, then one text block, then everytool_use) and attachments are gone entirely, becauseUIMessage.contentis a plain string. That result is a plausible history, not the original one.
So when you hold the real thing, persist it: a binding that keeps { ui, canonical } (as useChat does) should save history.canonical rather than round-tripping through canonicalFromUI.
Branching: regenerate and edit-and-resend
A chat binding maintains two views of the same conversation — ChatHistory is the pair:
interface ChatHistory {
ui: UIMessage[];
canonical: Message[];
}Because history is immutable, a branch is just a prefix: a new array cut before the point you re-run. Two helpers do the cutting on both views at once:
dropTrailingAssistant(history)— regenerate: drop the trailing assistant/tool turns from both views so the last user turn runs again. No-op when nothing trails.branchBeforeUserMessage(history, messageId)— edit-and-resend: cut both views to just before the user turn holdingmessageId. The canonical cut uses the user-turn ordinal (not array index), so assistant/tool interleaving can never skew the pairing. ReturnsundefinedwhenmessageIdis not a user message.
import { branchBeforeUserMessage } from '@deuz-sdk/core/chat';
const branched = branchBeforeUserMessage(history, editedMessageId);
if (branched) {
const messages = [...branched.canonical, { role: 'user' as const, content: editedText }];
// Persist the fork as a NEW chat, keeping lineage via parentId:
streamChat({
model,
messages,
chat: { store, chatId: newChatId, scope, parentId: originalChatId },
});
}parentId on the saved ChatRecord is how forks stay traceable — a linear chat never sets it; every edit-and-resend fork points at the chat it branched from.
Related
- UI Streaming — the wire parts
applyUIPartconsumes, including the 1.7 and 1.9 additions. - Request validation —
validateChatRequest, re-exported from this same subpath, for the route that receives the history. - Memory — the shared
MemoryScopemodel, and thememorycall option for cross-session facts (persistence stores transcripts; memory stores knowledge). - The unbreakable chatbot — persisting the live stream; this page persists the conversation.
- Durable Runtime — the
$deuzBytescodec convention and the SessionStore pattern this seam follows. - React hooks —
useChat, the binding built on this engine.
Persistent Stores
SQLite, Redis and Postgres packs — one factory each for the MemoryStore, ChatStore, SessionStore and RunStore seams, with real schemas and no runtime dependencies.
Compaction & Context Recovery
compactMessages — the loop's layered compaction as a plain function — plus the rolling summary, automatic overflow recovery, and a real tokenizer via countTokens.