1014 lines
31 KiB
TypeScript
1014 lines
31 KiB
TypeScript
import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/chat-messages'
|
|
|
|
/**
|
|
* Crash-survivable in-flight turn journal.
|
|
*
|
|
* While a session is busy, the visible tail of the running turn (user prompt +
|
|
* streamed assistant rows, tool calls included) is persisted to localStorage.
|
|
* If the renderer or the whole app dies mid-turn, session resume folds the
|
|
* journaled tail back onto the restored transcript, so streamed progress is
|
|
* not silently lost. The backend's own `inflight` snapshot (merged by
|
|
* `appendLiveSessionProjection`) covers reconnects while the backend is alive;
|
|
* this journal covers the cases where the backend died too — and it is richer,
|
|
* because the backend snapshot carries text only while the journal keeps the
|
|
* full part structure.
|
|
*
|
|
* Best-effort by design: storage failures must never break chat streaming.
|
|
*/
|
|
|
|
const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
|
|
const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
|
|
const LEGACY_MIGRATION_KEY = 'hermes.desktop.inflightTurnJournal.v2.migrated'
|
|
const DISCARDED_SNAPSHOT_RAW = '0'
|
|
const STORE_VERSION = 1
|
|
const MAX_SESSION_STORE_CHARS = 4 * 1024 * 1024
|
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
// Keep the worst-case v2 namespace below a conservative localStorage budget
|
|
// while retaining the 24 newest session slots for ordinary small snapshots.
|
|
const MAX_ENTRY_CHARS = 160 * 1024
|
|
const MAX_ENTRIES = Math.min(24, Math.floor(MAX_SESSION_STORE_CHARS / MAX_ENTRY_CHARS))
|
|
const MAX_LEGACY_STORE_CHARS = 2 * 1024 * 1024
|
|
const MAX_SESSION_KEY_CHARS = 512
|
|
const MAX_JOURNALED_MESSAGES = 24
|
|
const MAX_TEXT_PART_CHARS = 64 * 1024
|
|
const MAX_METADATA_CHARS = 2 * 1024
|
|
const MAX_USER_ATTACHMENT_REFS = 256
|
|
const MAX_USER_ATTACHMENT_REF_CHARS = 64 * 1024
|
|
/** Streaming repaints arrive every ~33ms; localStorage writes are synchronous.
|
|
* Trailing-edge throttle keeps the journal off the hot path — a crash costs at
|
|
* most this much of the newest tail. */
|
|
const PERSIST_THROTTLE_MS = 400
|
|
|
|
// A renderer can accumulate one entry per session over its lifetime. Sweep the
|
|
// bounded v2 namespace once on first journal access; never scan it on the
|
|
// 400ms streaming write path.
|
|
let sessionStoreSwept = false
|
|
|
|
export interface InFlightTurnSnapshot {
|
|
messages: ChatMessage[]
|
|
streamId: null | string
|
|
turnStartedAt: null | number
|
|
updatedAt: number
|
|
}
|
|
|
|
export interface JournalableSessionState {
|
|
awaitingResponse: boolean
|
|
busy: boolean
|
|
messages: ChatMessage[]
|
|
storedSessionId: null | string
|
|
streamId: null | string
|
|
turnStartedAt: null | number
|
|
}
|
|
|
|
interface JournalStore {
|
|
entries: Record<string, InFlightTurnSnapshot>
|
|
version: typeof STORE_VERSION
|
|
}
|
|
|
|
export interface InFlightRecoveryResult {
|
|
applied: boolean
|
|
/** The base transcript already contains the journaled turn's completed
|
|
* reply — the journal entry is stale and has been cleared. */
|
|
caughtUp: boolean
|
|
messages: ChatMessage[]
|
|
streamId: null | string
|
|
turnStartedAt: null | number
|
|
}
|
|
|
|
function storage(): Storage | null {
|
|
try {
|
|
return typeof window === 'undefined' ? null : window.localStorage
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function sessionStorageKey(storedSessionId: string): null | string {
|
|
try {
|
|
const encoded = encodeURIComponent(storedSessionId)
|
|
|
|
return encoded.length > 0 && encoded.length <= MAX_SESSION_KEY_CHARS ? `${STORAGE_PREFIX}${encoded}` : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readRaw(store: Storage, key: string): null | string {
|
|
try {
|
|
return store.getItem(key)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function removeRaw(store: Storage, key: string): void {
|
|
try {
|
|
store.removeItem(key)
|
|
} catch {
|
|
// Best-effort recovery state must not interrupt chat streaming.
|
|
}
|
|
}
|
|
|
|
function writeRaw(store: Storage, key: string, value: string): boolean {
|
|
try {
|
|
store.setItem(key, value)
|
|
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function isSnapshot(value: unknown): value is InFlightTurnSnapshot {
|
|
if (!value || typeof value !== 'object') {
|
|
return false
|
|
}
|
|
|
|
const snapshot = value as Partial<InFlightTurnSnapshot>
|
|
|
|
return (
|
|
Array.isArray(snapshot.messages) &&
|
|
snapshot.messages.every(
|
|
message =>
|
|
Boolean(message) &&
|
|
typeof message === 'object' &&
|
|
typeof message.id === 'string' &&
|
|
['assistant', 'system', 'tool', 'user'].includes(message.role) &&
|
|
Array.isArray(message.parts) &&
|
|
message.parts.every(
|
|
part =>
|
|
Boolean(part) &&
|
|
typeof part === 'object' &&
|
|
typeof part.type === 'string' &&
|
|
(part.type !== 'text' && part.type !== 'reasoning'
|
|
? part.type !== 'tool-call' ||
|
|
(typeof part.toolName === 'string' &&
|
|
(part.toolCallId === undefined || typeof part.toolCallId === 'string') &&
|
|
(part.isError === undefined || typeof part.isError === 'boolean'))
|
|
: typeof part.text === 'string' && (part.parentId === undefined || typeof part.parentId === 'string'))
|
|
) &&
|
|
(message.timestamp === undefined ||
|
|
(typeof message.timestamp === 'number' && Number.isFinite(message.timestamp))) &&
|
|
(message.pending === undefined || typeof message.pending === 'boolean') &&
|
|
(message.error === undefined || typeof message.error === 'string') &&
|
|
(message.branchGroupId === undefined || typeof message.branchGroupId === 'string') &&
|
|
(message.hidden === undefined || typeof message.hidden === 'boolean') &&
|
|
(message.interim === undefined || typeof message.interim === 'boolean') &&
|
|
(message.attachmentRefs === undefined ||
|
|
(Array.isArray(message.attachmentRefs) && message.attachmentRefs.every(ref => typeof ref === 'string'))) &&
|
|
(message.rowId === undefined || (typeof message.rowId === 'number' && Number.isFinite(message.rowId)))
|
|
) &&
|
|
(snapshot.streamId === null || typeof snapshot.streamId === 'string') &&
|
|
(snapshot.turnStartedAt === null || typeof snapshot.turnStartedAt === 'number') &&
|
|
typeof snapshot.updatedAt === 'number' &&
|
|
Number.isFinite(snapshot.updatedAt)
|
|
)
|
|
}
|
|
|
|
function parseSnapshot(raw: string): InFlightTurnSnapshot | null {
|
|
if (raw.length > MAX_ENTRY_CHARS) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
|
|
return isSnapshot(parsed) ? parsed : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function serializeSnapshot(snapshot: InFlightTurnSnapshot): string | null {
|
|
let messages = snapshot.messages
|
|
|
|
while (messages.length > 0) {
|
|
try {
|
|
const raw = JSON.stringify({ ...snapshot, messages })
|
|
|
|
if (raw.length <= MAX_ENTRY_CHARS) {
|
|
return raw
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
// Keep the join-key row and newest assistant progress while dropping the
|
|
// oldest sealed rows. If those two rows alone do not fit, the caller must
|
|
// avoid replacing an older recoverable snapshot with a tombstone.
|
|
if (messages.length <= 2) {
|
|
return null
|
|
}
|
|
|
|
messages = [messages[0], ...messages.slice(2)]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function sweepSessionStore(store: Storage, reserveSlot = false): void {
|
|
if (sessionStoreSwept) {
|
|
return
|
|
}
|
|
|
|
sessionStoreSwept = true
|
|
|
|
try {
|
|
const sessionKeys: string[] = []
|
|
|
|
for (let index = 0; index < store.length; index += 1) {
|
|
const key = store.key(index)
|
|
|
|
if (key?.startsWith(STORAGE_PREFIX)) {
|
|
sessionKeys.push(key)
|
|
}
|
|
}
|
|
|
|
const liveEntries: Array<{ key: string; snapshot: InFlightTurnSnapshot }> = []
|
|
const migrated = readRaw(store, LEGACY_MIGRATION_KEY) !== null
|
|
|
|
for (const key of sessionKeys) {
|
|
const raw = readRaw(store, key)
|
|
|
|
// A tombstone is intentional state. It suppresses the stale v1
|
|
// predecessor until the one-shot migration removes the aggregate.
|
|
if (raw === DISCARDED_SNAPSHOT_RAW) {
|
|
if (migrated) {
|
|
removeRaw(store, key)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
const snapshot = raw ? parseSnapshot(raw) : null
|
|
|
|
if (!snapshot || isExpired(snapshot)) {
|
|
removeRaw(store, key)
|
|
|
|
continue
|
|
}
|
|
|
|
liveEntries.push({ key, snapshot })
|
|
}
|
|
|
|
liveEntries
|
|
.sort((left, right) => right.snapshot.updatedAt - left.snapshot.updatedAt)
|
|
.slice(reserveSlot ? MAX_ENTRIES - 1 : MAX_ENTRIES)
|
|
.forEach(entry => removeRaw(store, entry.key))
|
|
} catch {
|
|
// The journal is best effort; a storage enumeration failure must not
|
|
// interrupt renderer work or turn persistence.
|
|
}
|
|
}
|
|
|
|
function boundedString(value: string, maxChars: number): string {
|
|
return value.length <= maxChars ? value : value.slice(0, maxChars)
|
|
}
|
|
|
|
function boundedPart(part: ChatMessagePart): ChatMessagePart | null {
|
|
if (part.type === 'text') {
|
|
return {
|
|
type: 'text',
|
|
text: boundedString(part.text, MAX_TEXT_PART_CHARS),
|
|
...(part.parentId === undefined ? {} : { parentId: boundedString(part.parentId, MAX_METADATA_CHARS) })
|
|
}
|
|
}
|
|
|
|
if (part.type === 'reasoning') {
|
|
return {
|
|
type: 'reasoning',
|
|
text: boundedString(part.text, MAX_TEXT_PART_CHARS),
|
|
...(part.parentId === undefined ? {} : { parentId: boundedString(part.parentId, MAX_METADATA_CHARS) })
|
|
}
|
|
}
|
|
|
|
if (part.type === 'tool-call') {
|
|
// Tool payloads can contain multi-megabyte command output. Recovery only
|
|
// needs invocation identity and failure state; args/results are available
|
|
// from the backend transcript when it survives.
|
|
return {
|
|
type: 'tool-call',
|
|
toolName: boundedString(part.toolName, MAX_METADATA_CHARS),
|
|
args: {},
|
|
...(part.toolCallId === undefined ? {} : { toolCallId: boundedString(part.toolCallId, MAX_METADATA_CHARS) }),
|
|
...(part.result === undefined ? {} : { result: {} }),
|
|
...(part.isError === undefined ? {} : { isError: part.isError })
|
|
}
|
|
}
|
|
|
|
// Rich file/image/data/source parts can embed large payloads. They are not
|
|
// required for in-flight text/tool recovery and remain backend-owned.
|
|
return null
|
|
}
|
|
|
|
function boundedMessages(messages: ChatMessage[]): ChatMessage[] | null {
|
|
const bounded =
|
|
messages.length <= MAX_JOURNALED_MESSAGES
|
|
? messages
|
|
: [messages[0], ...messages.slice(-(MAX_JOURNALED_MESSAGES - 1))]
|
|
|
|
// User text and attachment refs are the recovery join key. Truncating either
|
|
// could attach a journal tail to the wrong transcript row, so pathological
|
|
// prompts skip journaling instead of weakening the match.
|
|
if (
|
|
bounded.some(message => {
|
|
if (message.role !== 'user') {
|
|
return false
|
|
}
|
|
|
|
if (
|
|
message.parts.some(
|
|
part => (part.type === 'text' || part.type === 'reasoning') && part.text.length > MAX_TEXT_PART_CHARS
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
const refs = message.attachmentRefs
|
|
|
|
if (!refs) {
|
|
return false
|
|
}
|
|
|
|
if (refs.length > MAX_USER_ATTACHMENT_REFS) {
|
|
return true
|
|
}
|
|
|
|
let chars = 0
|
|
|
|
for (const ref of refs) {
|
|
chars += ref.length
|
|
|
|
if (chars > MAX_USER_ATTACHMENT_REF_CHARS) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
})
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return bounded.map(message => ({
|
|
id: boundedString(message.id, MAX_METADATA_CHARS),
|
|
role: message.role,
|
|
parts: message.parts.map(boundedPart).filter((part): part is ChatMessagePart => part !== null),
|
|
...(message.timestamp === undefined ? {} : { timestamp: message.timestamp }),
|
|
...(message.pending === undefined ? {} : { pending: message.pending }),
|
|
...(message.error === undefined ? {} : { error: boundedString(message.error, MAX_METADATA_CHARS) }),
|
|
...(message.branchGroupId === undefined
|
|
? {}
|
|
: { branchGroupId: boundedString(message.branchGroupId, MAX_METADATA_CHARS) }),
|
|
...(message.hidden === undefined ? {} : { hidden: message.hidden }),
|
|
...(message.interim === undefined ? {} : { interim: message.interim }),
|
|
...(message.attachmentRefs === undefined
|
|
? {}
|
|
: {
|
|
attachmentRefs:
|
|
message.role === 'user'
|
|
? [...message.attachmentRefs]
|
|
: message.attachmentRefs
|
|
.slice(0, MAX_USER_ATTACHMENT_REFS)
|
|
.map(ref => boundedString(ref, MAX_METADATA_CHARS))
|
|
}),
|
|
...(message.rowId === undefined ? {} : { rowId: message.rowId })
|
|
}))
|
|
}
|
|
|
|
function migrateLegacyStore(store: Storage): void {
|
|
if (readRaw(store, LEGACY_MIGRATION_KEY) !== null) {
|
|
return
|
|
}
|
|
|
|
const raw = readRaw(store, LEGACY_STORAGE_KEY)
|
|
|
|
if (raw === null) {
|
|
return
|
|
}
|
|
|
|
// Claim the migration before touching the aggregate. If storage is failing,
|
|
// skip legacy recovery rather than retrying an expensive parse on every read.
|
|
if (!writeRaw(store, LEGACY_MIGRATION_KEY, '1')) {
|
|
return
|
|
}
|
|
|
|
// Release the multi-megabyte aggregate before allocating per-session v2
|
|
// entries. The captured string remains available for this one migration.
|
|
removeRaw(store, LEGACY_STORAGE_KEY)
|
|
|
|
if (!raw) {
|
|
return
|
|
}
|
|
|
|
if (raw.length > MAX_LEGACY_STORE_CHARS) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<JournalStore>
|
|
|
|
if (
|
|
parsed.version !== STORE_VERSION ||
|
|
!parsed.entries ||
|
|
typeof parsed.entries !== 'object' ||
|
|
Array.isArray(parsed.entries)
|
|
) {
|
|
return
|
|
}
|
|
|
|
const existingV2Keys = new Set<string>()
|
|
|
|
for (let index = 0; index < store.length; index += 1) {
|
|
const key = store.key(index)
|
|
|
|
if (key?.startsWith(STORAGE_PREFIX)) {
|
|
existingV2Keys.add(key)
|
|
}
|
|
}
|
|
|
|
const entries = Object.entries(parsed.entries)
|
|
.filter((entry): entry is [string, InFlightTurnSnapshot] => isSnapshot(entry[1]) && !isExpired(entry[1]))
|
|
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
|
.slice(0, Math.max(0, MAX_ENTRIES - existingV2Keys.size))
|
|
|
|
for (const [storedSessionId, snapshot] of entries) {
|
|
const key = sessionStorageKey(storedSessionId)
|
|
const messages = boundedMessages(snapshot.messages)
|
|
const value = messages ? serializeSnapshot({ ...snapshot, messages }) : null
|
|
|
|
// A v2 snapshot may have been written before the one-shot migration ran.
|
|
// Never replace newer per-session state with its stale v1 predecessor.
|
|
if (key && value && readRaw(store, key) === null) {
|
|
if (writeRaw(store, key, value)) {
|
|
existingV2Keys.add(key)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Malformed legacy data is discarded below.
|
|
}
|
|
}
|
|
|
|
function discardSnapshot(store: Storage, key: string): void {
|
|
// Migrate first so an existing v2 key suppresses its stale v1 predecessor,
|
|
// then remove the current session. This keeps every discard path from
|
|
// resurrecting legacy state on a later read.
|
|
migrateLegacyStore(store)
|
|
removeRaw(store, key)
|
|
}
|
|
|
|
function readSnapshot(storedSessionId: string): InFlightTurnSnapshot | null {
|
|
const store = storage()
|
|
const key = sessionStorageKey(storedSessionId)
|
|
|
|
if (!store || !key) {
|
|
return null
|
|
}
|
|
|
|
sweepSessionStore(store)
|
|
|
|
let raw = readRaw(store, key)
|
|
|
|
if (!raw) {
|
|
migrateLegacyStore(store)
|
|
raw = readRaw(store, key)
|
|
}
|
|
|
|
if (!raw) {
|
|
return null
|
|
}
|
|
|
|
const snapshot = parseSnapshot(raw)
|
|
|
|
if (!snapshot || isExpired(snapshot)) {
|
|
discardSnapshot(store, key)
|
|
|
|
return null
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
|
|
function removeSnapshot(storedSessionId: string): void {
|
|
const store = storage()
|
|
const key = sessionStorageKey(storedSessionId)
|
|
|
|
if (store && key) {
|
|
sweepSessionStore(store)
|
|
|
|
// Settling a session before the one-shot migration must clear its legacy
|
|
// entry too; otherwise a later read can migrate and resurrect stale state.
|
|
// This aggregate parse is terminal-transition work, never a stream write.
|
|
discardSnapshot(store, key)
|
|
}
|
|
}
|
|
|
|
function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean {
|
|
return now - entry.updatedAt > MAX_AGE_MS
|
|
}
|
|
|
|
function cloneMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
try {
|
|
return JSON.parse(JSON.stringify(messages)) as ChatMessage[]
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function normalizedText(value: string): string {
|
|
return value.replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function attachmentSignature(message: ChatMessage): string {
|
|
return (message.attachmentRefs ?? []).join('\n')
|
|
}
|
|
|
|
function userMessagesMatch(left: ChatMessage, right: ChatMessage): boolean {
|
|
return (
|
|
left.role === 'user' &&
|
|
right.role === 'user' &&
|
|
normalizedText(chatMessageText(left)) === normalizedText(chatMessageText(right)) &&
|
|
attachmentSignature(left) === attachmentSignature(right)
|
|
)
|
|
}
|
|
|
|
function partHasRecoverableContent(part: ChatMessagePart): boolean {
|
|
if (part.type === 'text' || part.type === 'reasoning') {
|
|
return typeof part.text === 'string' && part.text.trim().length > 0
|
|
}
|
|
|
|
return part.type === 'tool-call'
|
|
}
|
|
|
|
function assistantHasRecoverableContent(message: ChatMessage): boolean {
|
|
return message.role === 'assistant' && (Boolean(message.error) || message.parts.some(partHasRecoverableContent))
|
|
}
|
|
|
|
/** A live-turn projection row (backend `inflight` via appendLiveSessionProjection,
|
|
* or a still-streaming local bubble) — as opposed to a completed transcript row. */
|
|
function isLiveProjectionRow(message: ChatMessage): boolean {
|
|
return (
|
|
Boolean(message.pending) ||
|
|
message.id.startsWith('assistant-stream-') ||
|
|
message.id.startsWith('inflight-assistant-')
|
|
)
|
|
}
|
|
|
|
/** Visible tail of the running turn: the streaming assistant row (plus any
|
|
* interim rows sealed after it) back to the user prompt that started it. */
|
|
function recoverableTail(messages: ChatMessage[], streamId: null | string): ChatMessage[] {
|
|
const visible = messages.filter(message => !message.hidden)
|
|
let assistantIndex = -1
|
|
|
|
if (streamId) {
|
|
assistantIndex = visible.findIndex(message => message.id === streamId && assistantHasRecoverableContent(message))
|
|
}
|
|
|
|
if (assistantIndex < 0) {
|
|
for (let index = visible.length - 1; index >= 0; index -= 1) {
|
|
const message = visible[index]
|
|
|
|
if (message.role === 'user') {
|
|
break
|
|
}
|
|
|
|
if (assistantHasRecoverableContent(message)) {
|
|
assistantIndex = index
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (assistantIndex < 0) {
|
|
return []
|
|
}
|
|
|
|
let start = assistantIndex
|
|
|
|
for (let index = assistantIndex - 1; index >= 0; index -= 1) {
|
|
if (visible[index].role === 'user') {
|
|
start = index
|
|
|
|
// A mid-turn redirect inserts its correction as another user row right
|
|
// before the live reply, so the turn can open with a RUN of user rows.
|
|
// Keep walking back over them: stopping at the nearest one journals the
|
|
// correction alone and loses the prompt that actually started the turn.
|
|
while (start > 0 && visible[start - 1].role === 'user') {
|
|
start -= 1
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
return visible.slice(start)
|
|
}
|
|
|
|
function normalizeRecoveredTail(tail: ChatMessage[], keepPending: boolean): ChatMessage[] {
|
|
return cloneMessages(tail).map(message =>
|
|
message.role === 'assistant'
|
|
? {
|
|
...message,
|
|
pending: keepPending ? (message.pending ?? true) : false
|
|
}
|
|
: { ...message, pending: false }
|
|
)
|
|
}
|
|
|
|
function assistantTextLength(message: ChatMessage): number {
|
|
return chatMessageText(message).length
|
|
}
|
|
|
|
/** Merge the journal's last assistant row into the base's live projection row.
|
|
*
|
|
* The journal carries structure (tool calls, reasoning) the backend snapshot
|
|
* lacks; the backend text may be newer than the journal's last throttled
|
|
* write. Keep the journal's parts, but let the longer text win — and keep the
|
|
* BASE row's id so live deltas keep appending to the row the stream handler
|
|
* already targets.
|
|
*/
|
|
function hasStructuralParts(message: ChatMessage): boolean {
|
|
return message.parts.some(part => part.type === 'reasoning' || part.type === 'tool-call')
|
|
}
|
|
|
|
function overlayProjectionRow(projection: ChatMessage, journalRow: ChatMessage): ChatMessage {
|
|
// A projected error (retained failed turn) must survive the overlay.
|
|
const error = journalRow.error ?? projection.error
|
|
|
|
const merged: ChatMessage = {
|
|
...journalRow,
|
|
id: projection.id,
|
|
pending: projection.pending,
|
|
...(error ? { error } : {})
|
|
}
|
|
|
|
if (assistantTextLength(projection) <= assistantTextLength(journalRow)) {
|
|
return merged
|
|
}
|
|
|
|
// Backend text is newer than the journal's last throttled write — swap it
|
|
// into the journal's first text part, keeping tool calls and reasoning.
|
|
// When the journal already carries structure, only accept a *strict*
|
|
// extension of the answer text. A longer flat dump that starts with
|
|
// thinking chatter must not overwrite / insert as answer text (#76444).
|
|
const projectionText = chatMessageText(projection)
|
|
const journalText = chatMessageText(journalRow).trim()
|
|
|
|
if (hasStructuralParts(journalRow)) {
|
|
const next = projectionText.trim()
|
|
|
|
if (!journalText || !next.startsWith(journalText)) {
|
|
return merged
|
|
}
|
|
}
|
|
|
|
const parts: ChatMessagePart[] = []
|
|
let textReplaced = false
|
|
|
|
for (const part of journalRow.parts) {
|
|
if (part.type !== 'text') {
|
|
parts.push(part)
|
|
} else if (!textReplaced) {
|
|
parts.push({ ...part, text: projectionText })
|
|
textReplaced = true
|
|
}
|
|
}
|
|
|
|
if (!textReplaced) {
|
|
parts.push({ type: 'text', text: projectionText })
|
|
}
|
|
|
|
return { ...merged, parts }
|
|
}
|
|
|
|
/** Rows the base transcript doesn't already hold by id. The journal and the
|
|
* base can both carry the same row (a resume that replays a still-journaled
|
|
* turn), and appending it twice puts a duplicate id in the transcript —
|
|
* which assistant-ui's MessageRepository rejects by throwing. */
|
|
function withoutBaseIds(rows: ChatMessage[], baseMessages: ChatMessage[]): ChatMessage[] {
|
|
const baseIds = new Set(baseMessages.map(message => message.id))
|
|
|
|
return rows.filter(row => !baseIds.has(row.id))
|
|
}
|
|
|
|
/** Whether every recoverable assistant row in the journal tail already exists
|
|
* as committed text in the base transcript. When true, the journal outlived
|
|
* the turn it recorded and appending it would re-render the same answers at
|
|
* the end of the transcript (the "scrambled conversation" regression). */
|
|
function journalTailAlreadyCommitted(tailAssistants: ChatMessage[], baseMessages: ChatMessage[]): boolean {
|
|
const recoverable = tailAssistants.filter(assistantHasRecoverableContent)
|
|
|
|
if (recoverable.length === 0) {
|
|
return false
|
|
}
|
|
|
|
const baseTexts = new Set(
|
|
baseMessages
|
|
.filter(message => message.role === 'assistant' && !message.hidden)
|
|
.map(message => normalizedText(chatMessageText(message)))
|
|
)
|
|
|
|
return recoverable.every(message => {
|
|
const text = normalizedText(chatMessageText(message))
|
|
|
|
// Error-only rows carry no text to verify against — keep the conservative
|
|
// append path rather than risk dropping a recoverable failure.
|
|
return text.length > 0 && baseTexts.has(text)
|
|
})
|
|
}
|
|
|
|
export function mergeInFlightMessages(
|
|
baseMessages: ChatMessage[],
|
|
tailMessages: ChatMessage[],
|
|
options: { keepPending?: boolean } = {}
|
|
): InFlightRecoveryResult {
|
|
const noop: InFlightRecoveryResult = {
|
|
applied: false,
|
|
caughtUp: false,
|
|
messages: baseMessages,
|
|
streamId: null,
|
|
turnStartedAt: null
|
|
}
|
|
|
|
const tail = normalizeRecoveredTail(tailMessages, Boolean(options.keepPending))
|
|
|
|
if (!tail.some(assistantHasRecoverableContent)) {
|
|
return noop
|
|
}
|
|
|
|
const tailUserIndex = tail.findIndex(message => message.role === 'user')
|
|
const tailUser = tailUserIndex >= 0 ? tail[tailUserIndex] : null
|
|
const tailAssistants = tail.slice(tailUserIndex + 1)
|
|
const lastJournalRow = tailAssistants.findLast(assistantHasRecoverableContent) ?? null
|
|
const matchingUserIndex = tailUser ? baseMessages.findLastIndex(message => userMessagesMatch(message, tailUser)) : -1
|
|
|
|
if (matchingUserIndex < 0) {
|
|
// No base user matches the tail's user row (a projected user-inflight row
|
|
// that never persisted, or a tail captured without its user prompt). If the
|
|
// tail's answers are already committed in the transcript, the journal is
|
|
// stale — appending it would re-render the same replies at the end of the
|
|
// conversation. Otherwise, the base never saw this turn at all: append the
|
|
// whole tail (the crash-recovery path the journal exists for).
|
|
if (journalTailAlreadyCommitted(tailAssistants, baseMessages)) {
|
|
return { ...noop, caughtUp: true }
|
|
}
|
|
|
|
const streamId = lastJournalRow?.id ?? null
|
|
|
|
return {
|
|
applied: true,
|
|
caughtUp: false,
|
|
messages: [...baseMessages, ...withoutBaseIds(tail, baseMessages)],
|
|
// Only a genuinely running turn keeps a live stream target. On an idle
|
|
// resume, carrying the stale streamId would keep the journal entry alive
|
|
// (persistInFlightTurnState only clears when streamId is null) and the
|
|
// same tail would be folded again on every open.
|
|
streamId: options.keepPending ? streamId : null,
|
|
turnStartedAt: null
|
|
}
|
|
}
|
|
|
|
const afterUser = baseMessages.slice(matchingUserIndex + 1)
|
|
|
|
const completedReply = afterUser.find(
|
|
message => assistantHasRecoverableContent(message) && !isLiveProjectionRow(message)
|
|
)
|
|
|
|
if (completedReply) {
|
|
// The transcript already holds this turn's committed reply — the journal
|
|
// entry is stale.
|
|
return { ...noop, caughtUp: true }
|
|
}
|
|
|
|
const projectionIndex = baseMessages.findIndex(
|
|
(message, index) => index > matchingUserIndex && message.role === 'assistant' && isLiveProjectionRow(message)
|
|
)
|
|
|
|
if (projectionIndex < 0) {
|
|
if (tailAssistants.length === 0) {
|
|
return noop
|
|
}
|
|
|
|
const streamId = lastJournalRow?.id ?? null
|
|
|
|
return {
|
|
applied: true,
|
|
caughtUp: false,
|
|
messages: [...baseMessages, ...withoutBaseIds(tailAssistants, baseMessages)],
|
|
// Same idle-resume rule as the other exit paths: only a running turn
|
|
// keeps the stream target alive. Carrying the stale streamId here kept
|
|
// the journal entry alive (persistInFlightTurnState only clears when
|
|
// streamId is null), so the same tail was folded again on every open.
|
|
streamId: options.keepPending ? streamId : null,
|
|
turnStartedAt: null
|
|
}
|
|
}
|
|
|
|
// Backend projection row present (text-only): overlay the journal's
|
|
// structure onto it instead of treating it as "caught up" — that is how
|
|
// locally recorded tool progress used to get dropped.
|
|
const projection = baseMessages[projectionIndex]
|
|
const merged = lastJournalRow ? overlayProjectionRow(projection, lastJournalRow) : projection
|
|
|
|
const sealedRows = tailAssistants.filter(
|
|
message => message !== lastJournalRow && assistantHasRecoverableContent(message)
|
|
)
|
|
|
|
const messages = [
|
|
...baseMessages.slice(0, projectionIndex),
|
|
...sealedRows,
|
|
merged,
|
|
...baseMessages.slice(projectionIndex + 1)
|
|
]
|
|
|
|
return {
|
|
applied: true,
|
|
caughtUp: false,
|
|
messages,
|
|
// Same idle-resume rule as the append path: only a running turn keeps the
|
|
// stream target alive, so an idle resume clears the journal instead of
|
|
// re-folding the same tail on every open.
|
|
streamId: options.keepPending ? merged.id : null,
|
|
turnStartedAt: null
|
|
}
|
|
}
|
|
|
|
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
const persistLatest = new Map<string, JournalableSessionState>()
|
|
|
|
/** @internal Test-only reset for module-scoped throttles and sweep state. */
|
|
export function resetInFlightTurnJournalStateForTests(): void {
|
|
for (const timer of persistTimers.values()) {
|
|
clearTimeout(timer)
|
|
}
|
|
|
|
persistTimers.clear()
|
|
persistLatest.clear()
|
|
sessionStoreSwept = false
|
|
}
|
|
|
|
function writeSnapshot(storedSessionId: string, state: JournalableSessionState): void {
|
|
const tail = recoverableTail(state.messages, state.streamId)
|
|
|
|
if (tail.length === 0) {
|
|
return
|
|
}
|
|
|
|
const store = storage()
|
|
const key = sessionStorageKey(storedSessionId)
|
|
|
|
if (!store || !key) {
|
|
return
|
|
}
|
|
|
|
sweepSessionStore(store, true)
|
|
|
|
const messages = boundedMessages(tail)
|
|
|
|
if (!messages) {
|
|
// Keep the timer write path free of aggregate migration. This tiny invalid
|
|
// v2 value suppresses the stale v1 predecessor until read/settle performs
|
|
// the one-shot migration and removes it.
|
|
tombstoneUnlessRecoverable(store, key)
|
|
|
|
return
|
|
}
|
|
|
|
const raw = serializeSnapshot({
|
|
messages,
|
|
streamId: state.streamId,
|
|
turnStartedAt: state.turnStartedAt,
|
|
updatedAt: Date.now()
|
|
})
|
|
|
|
if (!raw) {
|
|
// Preserve an older bounded snapshot if the newest assistant row alone is
|
|
// too large. A tombstone is only needed when there is no recoverable v2
|
|
// value, so stale v1 state cannot be resurrected on a later read.
|
|
tombstoneUnlessRecoverable(store, key)
|
|
|
|
return
|
|
}
|
|
|
|
if (!writeRaw(store, key, raw)) {
|
|
// A quota failure must not leave an older, misleading snapshot behind, or
|
|
// let the stale v1 predecessor be resurrected on a later read.
|
|
tombstoneUnlessRecoverable(store, key)
|
|
}
|
|
}
|
|
|
|
function tombstoneUnlessRecoverable(store: Storage, key: string): void {
|
|
const previous = readRaw(store, key)
|
|
|
|
if (previous) {
|
|
const snapshot = parseSnapshot(previous)
|
|
|
|
if (snapshot && !isExpired(snapshot)) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (!writeRaw(store, key, DISCARDED_SNAPSHOT_RAW)) {
|
|
removeRaw(store, key)
|
|
}
|
|
}
|
|
|
|
/** Persist the running turn's visible tail (throttled), or clear the entry the
|
|
* moment the turn settles. Call on every session-state commit. */
|
|
export function persistInFlightTurnState(state: JournalableSessionState): void {
|
|
const storedSessionId = state.storedSessionId
|
|
|
|
if (!storedSessionId) {
|
|
return
|
|
}
|
|
|
|
if (!state.busy && !state.awaitingResponse && !state.streamId) {
|
|
clearInFlightTurnJournal(storedSessionId)
|
|
|
|
return
|
|
}
|
|
|
|
persistLatest.set(storedSessionId, state)
|
|
|
|
if (persistTimers.has(storedSessionId)) {
|
|
return
|
|
}
|
|
|
|
persistTimers.set(
|
|
storedSessionId,
|
|
setTimeout(() => {
|
|
persistTimers.delete(storedSessionId)
|
|
const latest = persistLatest.get(storedSessionId)
|
|
|
|
persistLatest.delete(storedSessionId)
|
|
|
|
if (latest) {
|
|
writeSnapshot(storedSessionId, latest)
|
|
}
|
|
}, PERSIST_THROTTLE_MS)
|
|
)
|
|
}
|
|
|
|
export function readInFlightTurnJournal(storedSessionId: null | string): InFlightTurnSnapshot | null {
|
|
if (!storedSessionId) {
|
|
return null
|
|
}
|
|
|
|
return readSnapshot(storedSessionId)
|
|
}
|
|
|
|
/** Fold a journaled in-flight tail back onto a restored transcript. A no-op
|
|
* returns `baseMessages` by reference so callers keep their fast-path ref. */
|
|
export function recoverInFlightTurnJournal(
|
|
storedSessionId: null | string,
|
|
baseMessages: ChatMessage[],
|
|
options: { keepPending?: boolean } = {}
|
|
): InFlightRecoveryResult {
|
|
const snapshot = readInFlightTurnJournal(storedSessionId)
|
|
|
|
if (!snapshot) {
|
|
return {
|
|
applied: false,
|
|
caughtUp: false,
|
|
messages: baseMessages,
|
|
streamId: null,
|
|
turnStartedAt: null
|
|
}
|
|
}
|
|
|
|
const recovered = mergeInFlightMessages(baseMessages, snapshot.messages, options)
|
|
|
|
if (recovered.caughtUp) {
|
|
clearInFlightTurnJournal(storedSessionId)
|
|
}
|
|
|
|
return {
|
|
...recovered,
|
|
// Never resurrect a stale stream target on an idle resume: with
|
|
// keepPending=false the session is not running, so the recovered rows are
|
|
// settled history and the journal must clear on the next state update —
|
|
// otherwise the same stale tail is folded again on every open.
|
|
streamId: recovered.applied ? (recovered.streamId ?? (options.keepPending ? snapshot.streamId : null)) : null,
|
|
turnStartedAt: recovered.applied ? snapshot.turnStartedAt : null
|
|
}
|
|
}
|
|
|
|
export function clearInFlightTurnJournal(storedSessionId: null | string): void {
|
|
if (!storedSessionId) {
|
|
return
|
|
}
|
|
|
|
const timer = persistTimers.get(storedSessionId)
|
|
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
persistTimers.delete(storedSessionId)
|
|
}
|
|
|
|
persistLatest.delete(storedSessionId)
|
|
|
|
removeSnapshot(storedSessionId)
|
|
}
|