useLogMessages.ts
hooks/useLogMessages.ts
No strong subsystem tag
120
Lines
5710
Bytes
1
Exports
6
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 120 lines, 6 detected imports, and 1 detected exports.
Important relationships
Detected exports
useLogMessages
Keywords
messagesuuidcurrentrecordtranscriptcompactionisincrementalmessageteamcontextfirstmessageuuidrefcurrentfirstuuid
Detected imports
cryptoreact../state/AppState.js../types/message.js../utils/agentSwarmsEnabled.js../utils/sessionStorage.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
import type { UUID } from 'crypto'
import { useEffect, useRef } from 'react'
import { useAppState } from '../state/AppState.js'
import type { Message } from '../types/message.js'
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
import {
cleanMessagesForLogging,
isChainParticipant,
recordTranscript,
} from '../utils/sessionStorage.js'
/**
* Hook that logs messages to the transcript
* conversation ID that only changes when a new conversation is started.
*
* @param messages The current conversation messages
* @param ignore When true, messages will not be recorded to the transcript
*/
export function useLogMessages(messages: Message[], ignore: boolean = false) {
const teamContext = useAppState(s => s.teamContext)
// messages is append-only between compactions, so track where we left off
// and only pass the new tail to recordTranscript. Avoids O(n) filter+scan
// on every setMessages (~20x/turn, so n=3000 was ~120k wasted iterations).
const lastRecordedLengthRef = useRef(0)
const lastParentUuidRef = useRef<UUID | undefined>(undefined)
// First-uuid change = compaction or /clear rebuilt the array; length alone
// can't detect this since post-compact [CB,summary,...keep,new] may be longer.
const firstMessageUuidRef = useRef<UUID | undefined>(undefined)
// Guard against stale async .then() overwriting a fresher sync update when
// an incremental render fires before the compaction .then() resolves.
const callSeqRef = useRef(0)
useEffect(() => {
if (ignore) return
const currentFirstUuid = messages[0]?.uuid as UUID | undefined
const prevLength = lastRecordedLengthRef.current
// First-render: firstMessageUuidRef is undefined. Compaction: first uuid changes.
// Both are !isIncremental, but first-render sync-walk is safe (no messagesToKeep).
const wasFirstRender = firstMessageUuidRef.current === undefined
const isIncremental =
currentFirstUuid !== undefined &&
!wasFirstRender &&
currentFirstUuid === firstMessageUuidRef.current &&
prevLength <= messages.length
// Same-head shrink: tombstone filter, rewind, snip, partial-compact.
// Distinguished from compaction (first uuid changes) because the tail
// is either an existing on-disk message or a fresh message that this
// same effect's recordTranscript(fullArray) will write — see sync-walk
// guard below.
const isSameHeadShrink =
currentFirstUuid !== undefined &&
!wasFirstRender &&
currentFirstUuid === firstMessageUuidRef.current &&
prevLength > messages.length
const startIndex = isIncremental ? prevLength : 0
if (startIndex === messages.length) return
// Full array on first call + after compaction: recordTranscript's own
// O(n) dedup loop handles messagesToKeep interleaving correctly there.
const slice = startIndex === 0 ? messages : messages.slice(startIndex)
const parentHint = isIncremental ? lastParentUuidRef.current : undefined
// Fire and forget - we don't want to block the UI.
const seq = ++callSeqRef.current
void recordTranscript(
slice,
isAgentSwarmsEnabled()
? {
teamName: teamContext?.teamName,
agentName: teamContext?.selfAgentName,
}
: {},
parentHint,
messages,
).then(lastRecordedUuid => {
// For compaction/full array case (!isIncremental): use the async return
// value. After compaction, messagesToKeep in the array are skipped
// (already in transcript), so the sync loop would find a wrong UUID.
// Skip if a newer effect already ran (stale closure would overwrite the
// fresher sync update from the subsequent incremental render).
if (seq !== callSeqRef.current) return
if (lastRecordedUuid && !isIncremental) {
lastParentUuidRef.current = lastRecordedUuid
}
})
// Sync-walk safe for: incremental (pure new-tail slice), first-render
// (no messagesToKeep interleaving), and same-head shrink. Shrink is the
// subtle one: the picked uuid is either already on disk (tombstone/rewind
// — survivors were written before) or is being written by THIS effect's
// recordTranscript(fullArray) call (snip boundary / partial-compact tail
// — enqueueWrite ordering guarantees it lands before any later write that
// chains to it). Without this, the ref stays stale at a tombstoned uuid:
// the async .then() correction is raced out by the next effect's seq bump
// on large sessions where recordTranscript(fullArray) is slow. Only the
// compaction case (first uuid changed) remains unsafe — tail may be
// messagesToKeep whose last-actually-recorded uuid differs.
if (isIncremental || wasFirstRender || isSameHeadShrink) {
// Match EXACTLY what recordTranscript persists: cleanMessagesForLogging
// applies both the isLoggableMessage filter and (for external users) the
// REPL-strip + isVirtual-promote transform. Using the raw predicate here
// would pick a UUID that the transform drops, leaving the parent hint
// pointing at a message that never reached disk. Pass full messages as
// replId context — REPL tool_use and its tool_result land in separate
// render cycles, so the slice alone can't pair them.
const last = cleanMessagesForLogging(slice, messages).findLast(
isChainParticipant,
)
if (last) lastParentUuidRef.current = last.uuid as UUID
}
lastRecordedLengthRef.current = messages.length
firstMessageUuidRef.current = currentFirstUuid
}, [messages, ignore, teamContext?.teamName, teamContext?.selfAgentName])
}