/ hooks / useLogMessages.ts
useLogMessages.ts
  1  import type { UUID } from 'crypto'
  2  import { useEffect, useRef } from 'react'
  3  import { useAppState } from '../state/AppState.js'
  4  import type { Message } from '../types/message.js'
  5  import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
  6  import {
  7    cleanMessagesForLogging,
  8    isChainParticipant,
  9    recordTranscript,
 10  } from '../utils/sessionStorage.js'
 11  
 12  /**
 13   * Hook that logs messages to the transcript
 14   * conversation ID that only changes when a new conversation is started.
 15   *
 16   * @param messages The current conversation messages
 17   * @param ignore When true, messages will not be recorded to the transcript
 18   */
 19  export function useLogMessages(messages: Message[], ignore: boolean = false) {
 20    const teamContext = useAppState(s => s.teamContext)
 21  
 22    // messages is append-only between compactions, so track where we left off
 23    // and only pass the new tail to recordTranscript. Avoids O(n) filter+scan
 24    // on every setMessages (~20x/turn, so n=3000 was ~120k wasted iterations).
 25    const lastRecordedLengthRef = useRef(0)
 26    const lastParentUuidRef = useRef<UUID | undefined>(undefined)
 27    // First-uuid change = compaction or /clear rebuilt the array; length alone
 28    // can't detect this since post-compact [CB,summary,...keep,new] may be longer.
 29    const firstMessageUuidRef = useRef<UUID | undefined>(undefined)
 30    // Guard against stale async .then() overwriting a fresher sync update when
 31    // an incremental render fires before the compaction .then() resolves.
 32    const callSeqRef = useRef(0)
 33  
 34    useEffect(() => {
 35      if (ignore) return
 36  
 37      const currentFirstUuid = messages[0]?.uuid as UUID | undefined
 38      const prevLength = lastRecordedLengthRef.current
 39  
 40      // First-render: firstMessageUuidRef is undefined. Compaction: first uuid changes.
 41      // Both are !isIncremental, but first-render sync-walk is safe (no messagesToKeep).
 42      const wasFirstRender = firstMessageUuidRef.current === undefined
 43      const isIncremental =
 44        currentFirstUuid !== undefined &&
 45        !wasFirstRender &&
 46        currentFirstUuid === firstMessageUuidRef.current &&
 47        prevLength <= messages.length
 48      // Same-head shrink: tombstone filter, rewind, snip, partial-compact.
 49      // Distinguished from compaction (first uuid changes) because the tail
 50      // is either an existing on-disk message or a fresh message that this
 51      // same effect's recordTranscript(fullArray) will write — see sync-walk
 52      // guard below.
 53      const isSameHeadShrink =
 54        currentFirstUuid !== undefined &&
 55        !wasFirstRender &&
 56        currentFirstUuid === firstMessageUuidRef.current &&
 57        prevLength > messages.length
 58  
 59      const startIndex = isIncremental ? prevLength : 0
 60      if (startIndex === messages.length) return
 61  
 62      // Full array on first call + after compaction: recordTranscript's own
 63      // O(n) dedup loop handles messagesToKeep interleaving correctly there.
 64      const slice = startIndex === 0 ? messages : messages.slice(startIndex)
 65      const parentHint = isIncremental ? lastParentUuidRef.current : undefined
 66  
 67      // Fire and forget - we don't want to block the UI.
 68      const seq = ++callSeqRef.current
 69      void recordTranscript(
 70        slice,
 71        isAgentSwarmsEnabled()
 72          ? {
 73              teamName: teamContext?.teamName,
 74              agentName: teamContext?.selfAgentName,
 75            }
 76          : {},
 77        parentHint,
 78        messages,
 79      ).then(lastRecordedUuid => {
 80        // For compaction/full array case (!isIncremental): use the async return
 81        // value. After compaction, messagesToKeep in the array are skipped
 82        // (already in transcript), so the sync loop would find a wrong UUID.
 83        // Skip if a newer effect already ran (stale closure would overwrite the
 84        // fresher sync update from the subsequent incremental render).
 85        if (seq !== callSeqRef.current) return
 86        if (lastRecordedUuid && !isIncremental) {
 87          lastParentUuidRef.current = lastRecordedUuid
 88        }
 89      })
 90  
 91      // Sync-walk safe for: incremental (pure new-tail slice), first-render
 92      // (no messagesToKeep interleaving), and same-head shrink. Shrink is the
 93      // subtle one: the picked uuid is either already on disk (tombstone/rewind
 94      // — survivors were written before) or is being written by THIS effect's
 95      // recordTranscript(fullArray) call (snip boundary / partial-compact tail
 96      // — enqueueWrite ordering guarantees it lands before any later write that
 97      // chains to it). Without this, the ref stays stale at a tombstoned uuid:
 98      // the async .then() correction is raced out by the next effect's seq bump
 99      // on large sessions where recordTranscript(fullArray) is slow. Only the
100      // compaction case (first uuid changed) remains unsafe — tail may be
101      // messagesToKeep whose last-actually-recorded uuid differs.
102      if (isIncremental || wasFirstRender || isSameHeadShrink) {
103        // Match EXACTLY what recordTranscript persists: cleanMessagesForLogging
104        // applies both the isLoggableMessage filter and (for external users) the
105        // REPL-strip + isVirtual-promote transform. Using the raw predicate here
106        // would pick a UUID that the transform drops, leaving the parent hint
107        // pointing at a message that never reached disk. Pass full messages as
108        // replId context — REPL tool_use and its tool_result land in separate
109        // render cycles, so the slice alone can't pair them.
110        const last = cleanMessagesForLogging(slice, messages).findLast(
111          isChainParticipant,
112        )
113        if (last) lastParentUuidRef.current = last.uuid as UUID
114      }
115  
116      lastRecordedLengthRef.current = messages.length
117      firstMessageUuidRef.current = currentFirstUuid
118    }, [messages, ignore, teamContext?.teamName, teamContext?.selfAgentName])
119  }