@@ -499,6 +499,113 @@ export class ChatContext {
499499 return await toChatCtx ( format , this , injectDummyUserMessage ) ;
500500 }
501501
502+ /**
503+ * Compare this ChatContext with another for logical equivalence.
504+ * Unlike strict equality, this method:
505+ * - Ignores timestamps (createdAt fields)
506+ * - Ignores other volatile metadata
507+ * - Focuses on content: compares IDs, types, and payload
508+ *
509+ * This is useful for detecting if the conversation content has changed,
510+ * for example when validating preemptive generation results.
511+ *
512+ * @param other - The ChatContext to compare with
513+ * @returns true if both contexts contain the same sequence of items with matching essential fields
514+ */
515+ isEquivalent ( other : ChatContext ) : boolean {
516+ // Same object reference
517+ if ( this === other ) {
518+ return true ;
519+ }
520+
521+ // Different lengths
522+ if ( this . _items . length !== other . _items . length ) {
523+ return false ;
524+ }
525+
526+ // Compare each item pair
527+ for ( let i = 0 ; i < this . _items . length ; i ++ ) {
528+ const a = this . _items [ i ] ! ;
529+ const b = other . _items [ i ] ! ;
530+
531+ // IDs and types must match
532+ if ( a . id !== b . id || a . type !== b . type ) {
533+ return false ;
534+ }
535+
536+ // Type-specific field comparison
537+ if ( a . type === 'message' && b . type === 'message' ) {
538+ // Compare role, content, and interrupted status (not timestamp)
539+ if ( a . role !== b . role || a . interrupted !== b . interrupted ) {
540+ return false ;
541+ }
542+
543+ // Compare content arrays
544+ if ( a . content . length !== b . content . length ) {
545+ return false ;
546+ }
547+
548+ for ( let j = 0 ; j < a . content . length ; j ++ ) {
549+ const ca = a . content [ j ] ! ;
550+ const cb = b . content [ j ] ! ;
551+
552+ // Both are strings
553+ if ( typeof ca === 'string' && typeof cb === 'string' ) {
554+ if ( ca !== cb ) {
555+ return false ;
556+ }
557+ }
558+ // Both are objects
559+ else if ( typeof ca === 'object' && typeof cb === 'object' ) {
560+ if ( ca . type !== cb . type ) {
561+ return false ;
562+ }
563+
564+ if ( ca . type === 'image_content' && cb . type === 'image_content' ) {
565+ // Compare essential image fields (not cache)
566+ if (
567+ ca . id !== cb . id ||
568+ ca . image !== cb . image ||
569+ ca . inferenceDetail !== cb . inferenceDetail ||
570+ ca . inferenceWidth !== cb . inferenceWidth ||
571+ ca . inferenceHeight !== cb . inferenceHeight ||
572+ ca . mimeType !== cb . mimeType
573+ ) {
574+ return false ;
575+ }
576+ } else if ( ca . type === 'audio_content' && cb . type === 'audio_content' ) {
577+ // Compare audio transcript (frames comparison would be too expensive)
578+ if ( ca . transcript !== cb . transcript ) {
579+ return false ;
580+ }
581+ }
582+ }
583+ // Mismatched types
584+ else {
585+ return false ;
586+ }
587+ }
588+ } else if ( a . type === 'function_call' && b . type === 'function_call' ) {
589+ // Compare name, callId, and args (not timestamp)
590+ if ( a . name !== b . name || a . callId !== b . callId || a . args !== b . args ) {
591+ return false ;
592+ }
593+ } else if ( a . type === 'function_call_output' && b . type === 'function_call_output' ) {
594+ // Compare name, callId, output, and isError (not timestamp)
595+ if (
596+ a . name !== b . name ||
597+ a . callId !== b . callId ||
598+ a . output !== b . output ||
599+ a . isError !== b . isError
600+ ) {
601+ return false ;
602+ }
603+ }
604+ }
605+
606+ return true ;
607+ }
608+
502609 /**
503610 * Internal helper used by `truncate` & `addMessage` to find the correct
504611 * insertion index for a timestamp so the list remains sorted.
0 commit comments