1- import type { ChatItem , MessageGroup , ToolCard } from "../types/chat-types.ts" ;
1+ import type { ChatItem , MessageGroup , NormalizedMessage , ToolCard } from "../types/chat-types.ts" ;
22import {
33 isAssistantHeartbeatAckForDisplay ,
44 stripHeartbeatTokenForDisplay ,
55} from "./heartbeat-display.ts" ;
6- import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts" ;
6+ import { CHAT_HISTORY_RENDER_CHAR_BUDGET , CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts" ;
77import { extractTextCached } from "./message-extract.ts" ;
88import { normalizeMessage , stripMessageDisplayMetadataText } from "./message-normalizer.ts" ;
99import { normalizeRoleForGrouping } from "./role-normalizer.ts" ;
@@ -66,12 +66,32 @@ function appendCanvasBlockToAssistantMessage(
6666 } ;
6767}
6868
69+ function asRecord ( value : unknown ) : Record < string , unknown > | null {
70+ return value && typeof value === "object" && ! Array . isArray ( value )
71+ ? ( value as Record < string , unknown > )
72+ : null ;
73+ }
74+
75+ function safeNormalizeMessage ( message : unknown ) : NormalizedMessage | null {
76+ if ( ! asRecord ( message ) ) {
77+ return null ;
78+ }
79+ try {
80+ return normalizeMessage ( message ) ;
81+ } catch {
82+ return null ;
83+ }
84+ }
85+
6986function extractChatMessagePreview ( toolMessage : unknown ) : {
7087 preview : Extract < NonNullable < ToolCard [ "preview" ] > , { kind : "canvas" } > ;
7188 text : string | null ;
7289 timestamp : number | null ;
7390} | null {
74- const normalized = normalizeMessage ( toolMessage ) ;
91+ const normalized = safeNormalizeMessage ( toolMessage ) ;
92+ if ( ! normalized ) {
93+ return null ;
94+ }
7595 const cards = extractToolCards ( toolMessage , "preview" ) ;
7696 for ( let index = cards . length - 1 ; index >= 0 ; index -- ) {
7797 const card = cards [ index ] ;
@@ -114,7 +134,7 @@ function findNearestAssistantMessageIndex(
114134 }
115135 return {
116136 index,
117- timestamp : normalizeMessage ( item . message ) . timestamp ?? null ,
137+ timestamp : safeNormalizeMessage ( item . message ) ? .timestamp ?? null ,
118138 } ;
119139 } )
120140 . filter ( Boolean ) as Array < { index : number ; timestamp : number | null } > ;
@@ -203,7 +223,10 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
203223}
204224
205225function collapseDuplicateDisplaySignature ( message : unknown ) : string | null {
206- const normalized = normalizeMessage ( message ) ;
226+ const normalized = safeNormalizeMessage ( message ) ;
227+ if ( ! normalized ) {
228+ return null ;
229+ }
207230 const role = normalizeRoleForGrouping ( normalized . role ) . toLowerCase ( ) ;
208231 if ( ! role || role === "tool" ) {
209232 return null ;
@@ -250,7 +273,10 @@ function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] {
250273}
251274
252275function hasRenderableNormalizedMessage ( message : unknown ) : boolean {
253- const normalized = normalizeMessage ( message ) ;
276+ const normalized = safeNormalizeMessage ( message ) ;
277+ if ( ! normalized ) {
278+ return false ;
279+ }
254280 return normalized . content . length > 0 || Boolean ( normalized . replyTarget ) ;
255281}
256282
@@ -260,7 +286,7 @@ function sanitizeStreamText(text: string): string {
260286}
261287
262288function rawMessageTimestamp ( message : unknown ) : number | null {
263- const timestamp = ( message as { timestamp ?: unknown } ) . timestamp ;
289+ const timestamp = asRecord ( message ) ? .timestamp ;
264290 return typeof timestamp === "number" && Number . isFinite ( timestamp ) ? timestamp : null ;
265291}
266292
@@ -301,6 +327,129 @@ function sortChatItemsByVisibleTime(items: ChatItem[]): ChatItem[] {
301327 . map ( ( { item } ) => item ) ;
302328}
303329
330+ type RawContentEstimateState = {
331+ visited : WeakSet < object > ;
332+ nodes : number ;
333+ } ;
334+
335+ const RAW_CONTENT_ESTIMATE_MAX_DEPTH = 8 ;
336+ const RAW_CONTENT_ESTIMATE_MAX_NODES = 400 ;
337+
338+ function addCapped ( total : number , amount : number , limit : number ) : number {
339+ return Math . min ( limit , total + Math . max ( 0 , amount ) ) ;
340+ }
341+
342+ function estimateRawContentChars (
343+ value : unknown ,
344+ limit : number ,
345+ state : RawContentEstimateState ,
346+ depth = 0 ,
347+ ) : number {
348+ if ( limit <= 0 ) {
349+ return 0 ;
350+ }
351+ if ( typeof value === "string" ) {
352+ return Math . min ( value . length , limit ) ;
353+ }
354+ if ( ! value || typeof value !== "object" ) {
355+ return 0 ;
356+ }
357+ if ( depth >= RAW_CONTENT_ESTIMATE_MAX_DEPTH || state . nodes >= RAW_CONTENT_ESTIMATE_MAX_NODES ) {
358+ return 0 ;
359+ }
360+ if ( state . visited . has ( value ) ) {
361+ return 0 ;
362+ }
363+ state . visited . add ( value ) ;
364+ state . nodes += 1 ;
365+
366+ if ( Array . isArray ( value ) ) {
367+ let chars = 0 ;
368+ for ( const item of value ) {
369+ chars = addCapped (
370+ chars ,
371+ estimateRawContentChars ( item , limit - chars , state , depth + 1 ) ,
372+ limit ,
373+ ) ;
374+ if ( chars >= limit ) {
375+ break ;
376+ }
377+ }
378+ return chars ;
379+ }
380+
381+ const record = value as Record < string , unknown > ;
382+ let chars = 0 ;
383+ for ( const key of [ "text" , "content" , "args" , "arguments" , "input" ] as const ) {
384+ chars = addCapped (
385+ chars ,
386+ estimateRawContentChars ( record [ key ] , limit - chars , state , depth + 1 ) ,
387+ limit ,
388+ ) ;
389+ if ( chars >= limit ) {
390+ break ;
391+ }
392+ }
393+ return chars ;
394+ }
395+
396+ function estimateMessageRenderChars ( message : unknown , limit : number ) : number {
397+ const record = asRecord ( message ) ;
398+ if ( ! record ) {
399+ return 1 ;
400+ }
401+ const state : RawContentEstimateState = { visited : new WeakSet < object > ( ) , nodes : 0 } ;
402+ let chars = 0 ;
403+ for ( const key of [ "content" , "text" , "args" , "arguments" , "input" ] as const ) {
404+ chars = addCapped ( chars , estimateRawContentChars ( record [ key ] , limit - chars , state ) , limit ) ;
405+ if ( chars >= limit ) {
406+ break ;
407+ }
408+ }
409+ return Math . max ( chars , 1 ) ;
410+ }
411+
412+ function isHiddenToolMessage ( message : unknown , showToolCalls : boolean ) : boolean {
413+ if ( showToolCalls ) {
414+ return false ;
415+ }
416+ return safeNormalizeMessage ( message ) ?. role . toLowerCase ( ) === "toolresult" ;
417+ }
418+
419+ function countVisibleHistoryMessages ( messages : unknown [ ] , showToolCalls : boolean ) : number {
420+ let count = 0 ;
421+ for ( const message of messages ) {
422+ if ( ! isHiddenToolMessage ( message , showToolCalls ) ) {
423+ count += 1 ;
424+ }
425+ }
426+ return count ;
427+ }
428+
429+ function resolveHistoryStartIndex ( messages : unknown [ ] , showToolCalls : boolean ) : number {
430+ let visibleCount = 0 ;
431+ let renderChars = 0 ;
432+ let startIndex = messages . length ;
433+ for ( let index = messages . length - 1 ; index >= 0 ; index -= 1 ) {
434+ const message = messages [ index ] ;
435+ if ( isHiddenToolMessage ( message , showToolCalls ) ) {
436+ continue ;
437+ }
438+ if ( visibleCount >= CHAT_HISTORY_RENDER_LIMIT ) {
439+ break ;
440+ }
441+ const remainingBudget = Math . max ( 1 , CHAT_HISTORY_RENDER_CHAR_BUDGET - renderChars + 1 ) ;
442+ const messageChars = estimateMessageRenderChars ( message , remainingBudget ) ;
443+ if ( visibleCount > 0 && renderChars + messageChars > CHAT_HISTORY_RENDER_CHAR_BUDGET ) {
444+ break ;
445+ }
446+ renderChars += messageChars ;
447+ visibleCount += 1 ;
448+ startIndex = index ;
449+ }
450+ return startIndex ;
451+ }
452+
304453export function buildChatItems ( props : BuildChatItemsProps ) : Array < ChatItem | MessageGroup > {
305454 let items : ChatItem [ ] = [ ] ;
306455 const history = ( Array . isArray ( props . messages ) ? props . messages : [ ] ) . filter (
@@ -314,22 +463,33 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
314463 text : string | null ;
315464 timestamp : number | null ;
316465 } > ;
317- const historyStart = Math . max ( 0 , history . length - CHAT_HISTORY_RENDER_LIMIT ) ;
318- if ( historyStart > 0 ) {
466+ const historyStart = resolveHistoryStartIndex ( history , props . showToolCalls ) ;
467+ const hiddenHistoryCount = countVisibleHistoryMessages (
468+ history . slice ( 0 , historyStart ) ,
469+ props . showToolCalls ,
470+ ) ;
471+ const visibleHistoryCount = countVisibleHistoryMessages (
472+ history . slice ( historyStart ) ,
473+ props . showToolCalls ,
474+ ) ;
475+ if ( hiddenHistoryCount > 0 ) {
319476 items . push ( {
320477 kind : "message" ,
321478 key : "chat:history:notice" ,
322479 message : {
323480 role : "system" ,
324- content : `Showing last ${ CHAT_HISTORY_RENDER_LIMIT } messages (${ historyStart } hidden).` ,
481+ content : `Showing last ${ visibleHistoryCount } messages (${ hiddenHistoryCount } hidden).` ,
325482 timestamp : Date . now ( ) ,
326483 } ,
327484 } ) ;
328485 }
329486 for ( let i = historyStart ; i < history . length ; i ++ ) {
330487 const msg = history [ i ] ;
331- const normalized = normalizeMessage ( msg ) ;
332- const raw = msg as Record < string , unknown > ;
488+ const normalized = safeNormalizeMessage ( msg ) ;
489+ if ( ! normalized ) {
490+ continue ;
491+ }
492+ const raw = asRecord ( msg ) ?? { } ;
333493 const marker = raw . __openclaw as Record < string , unknown > | undefined ;
334494 if ( marker && marker . kind === "compaction" ) {
335495 items . push ( {
@@ -433,7 +593,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
433593}
434594
435595function messageKey ( message : unknown , index : number ) : string {
436- const m = message as Record < string , unknown > ;
596+ const m = asRecord ( message ) ?? { } ;
437597 const toolCallId = typeof m . toolCallId === "string" ? m . toolCallId : "" ;
438598 if ( toolCallId ) {
439599 const role = typeof m . role === "string" ? m . role : "unknown" ;
0 commit comments