@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
89 lines (80 loc) • 2.76 kB
text/typescript
import type { ModelMessage } from '@tanstack/ai'
// Empty incoming keeps stored. Non-empty: the last incoming id that already
// exists in stored is a cutoff (reload drops the old assistant after that
// user). Same id is replaced in place, but a stored mid-conversation record
// stays when the incoming copy has none. New ids and messages with no id are
// appended.
/** How many stored messages the merge keeps. Less than `stored.length` on a reload. */
export function storedCutoff(
stored: ReadonlyArray<ModelMessage>,
incoming: ReadonlyArray<ModelMessage>,
) {
for (let index = incoming.length - 1; index >= 0; index--) {
const id = incoming[index]?.id
if (id === undefined) continue
const storedIndex = stored.findIndex((message) => message.id === id)
if (storedIndex >= 0) return storedIndex + 1
}
return stored.length
}
export function mergeStoredMessages(
stored: ReadonlyArray<ModelMessage>,
incoming: ReadonlyArray<ModelMessage>,
) {
if (incoming.length === 0) {
return stored.slice()
}
const prefix = stored.slice(0, storedCutoff(stored, incoming))
const incomingById = new Map<string, ModelMessage>()
for (const message of incoming) {
const id = message.id
if (id) incomingById.set(id, message)
}
const storedIds = new Set<string>()
const merged: Array<ModelMessage> = []
for (const message of prefix) {
const id = message.id
if (id) {
storedIds.add(id)
const replacement = incomingById.get(id)
merged.push(
replacement ? keepStoredRecord(replacement, message) : message,
)
continue
}
merged.push(message)
}
for (let index = 0; index < incoming.length; index++) {
const message = incoming[index]
if (!message) continue
const id = message.id
if (id && storedIds.has(id)) continue
// Attach/reload can post the stored transcript again with no ids. Keep the
// prefix row instead of appending a second copy of the same turn.
if (!id) {
const existing = merged[index]
if (
existing &&
existing.id === undefined &&
existing.role === message.role &&
existing.content === message.content
) {
continue
}
}
merged.push(message)
}
return merged
}
// A client never sends the engine's mid-conversation record (UIMessages have
// none), so keep the stored one. Without it, each turn starts a new start
// point and the prompt cache breaks once per turn.
function keepStoredRecord(
incoming: ModelMessage,
stored: ModelMessage,
): ModelMessage {
if (incoming.midConversationChange || !stored.midConversationChange) {
return incoming
}
return { ...incoming, midConversationChange: stored.midConversationChange }
}