@@ -8,10 +8,12 @@ import { describe, it, expect } from 'vitest';
88import {
99 hardenHistory ,
1010 SYNTHETIC_THOUGHT_SIGNATURE ,
11+ scrubContents ,
12+ scrubHistory ,
1113} from './historyHardening.js' ;
1214import type { HistoryTurn } from '../core/agentChatHistory.js' ;
1315import { deriveStableId } from './cryptoUtils.js' ;
14- import type { Part } from '@google/genai' ;
16+ import type { Part , Content } from '@google/genai' ;
1517
1618describe ( 'hardenHistory' , ( ) => {
1719 it ( 'should return an empty array if input is empty' , ( ) => {
@@ -375,4 +377,205 @@ describe('hardenHistory', () => {
375377 expect ( hardened [ 0 ] . content . parts ! [ 0 ] ) . not . toHaveProperty ( 'extraProp' ) ;
376378 expect ( hardened [ 0 ] . content . parts ! [ 0 ] ) . toHaveProperty ( 'text' , 'hello' ) ;
377379 } ) ;
380+
381+ it ( 'should completely filter out thought parts from the scrubbed history' , ( ) => {
382+ const history : HistoryTurn [ ] = [
383+ {
384+ id : '1' ,
385+ content : {
386+ role : 'user' ,
387+ parts : [ { text : 'User prompt' } ] ,
388+ } ,
389+ } ,
390+ {
391+ id : '2' ,
392+ content : {
393+ role : 'model' ,
394+ parts : [
395+ {
396+ text : 'Previous model thought...' ,
397+ thought : true ,
398+ } as unknown as Part ,
399+ { text : 'Actual conversational text response' } ,
400+ ] ,
401+ } ,
402+ } ,
403+ {
404+ id : '3' ,
405+ content : {
406+ role : 'user' ,
407+ parts : [ { text : 'User follow-up prompt' } ] ,
408+ } ,
409+ } ,
410+ ] ;
411+
412+ const hardened = hardenHistory ( history ) ;
413+ // Model turn (Turn 2, index 1 in hardened) should only contain the actual conversational text part
414+ const modelTurn = hardened [ 1 ] ;
415+ expect ( modelTurn . content . parts ) . toHaveLength ( 1 ) ;
416+ expect ( modelTurn . content . parts ! [ 0 ] ) . toHaveProperty (
417+ 'text' ,
418+ 'Actual conversational text response' ,
419+ ) ;
420+ expect ( modelTurn . content . parts ! [ 0 ] ) . not . toHaveProperty ( 'thought' ) ;
421+ } ) ;
422+
423+ it ( 'should remove the entire turn if it only contained thought parts and is now empty' , ( ) => {
424+ const history : HistoryTurn [ ] = [
425+ {
426+ id : '1' ,
427+ content : {
428+ role : 'user' ,
429+ parts : [ { text : 'User prompt' } ] ,
430+ } ,
431+ } ,
432+ {
433+ id : '2' ,
434+ content : {
435+ role : 'model' ,
436+ parts : [
437+ {
438+ text : 'Model is just thinking internally...' ,
439+ thought : true ,
440+ } as unknown as Part ,
441+ ] ,
442+ } ,
443+ } ,
444+ {
445+ id : '3' ,
446+ content : {
447+ role : 'user' ,
448+ parts : [ { text : 'User follow-up prompt' } ] ,
449+ } ,
450+ } ,
451+ ] ;
452+
453+ const hardened = hardenHistory ( history ) ;
454+ // After scrubbing, Turn 2 should have 0 parts.
455+ // The history mapping filters out empty turns, so the total turns should coalesce and reduce to 1 coalesced user turn.
456+ // Let's inspect the hardened array:
457+ // User prompt (Turn 1) + User follow-up prompt (Turn 3) will be coalesced into 1 User turn.
458+ expect ( hardened ) . toHaveLength ( 1 ) ;
459+ expect ( hardened [ 0 ] . content . role ) . toBe ( 'user' ) ;
460+ expect ( hardened [ 0 ] . content . parts ) . toEqual ( [
461+ { text : 'User prompt' } ,
462+ { text : 'User follow-up prompt' } ,
463+ ] ) ;
464+ } ) ;
465+ } ) ;
466+
467+ describe ( 'scrubContents' , ( ) => {
468+ it ( 'should scrub non-standard fields from parts' , ( ) => {
469+ const contents : Content [ ] = [
470+ {
471+ role : 'user' ,
472+ parts : [ { text : 'Hello' , customField : 'ignored' } as unknown as Part ] ,
473+ } ,
474+ ] ;
475+ const scrubbed = scrubContents ( contents ) ;
476+ expect ( scrubbed ) . toEqual ( [
477+ {
478+ role : 'user' ,
479+ parts : [ { text : 'Hello' } ] ,
480+ } ,
481+ ] ) ;
482+ } ) ;
483+
484+ it ( 'should filter out internal thought parts' , ( ) => {
485+ const contents : Content [ ] = [
486+ {
487+ role : 'model' ,
488+ parts : [
489+ { text : 'thought' , thought : true } as unknown as Part ,
490+ { text : 'response' } ,
491+ ] ,
492+ } ,
493+ ] ;
494+ const scrubbed = scrubContents ( contents ) ;
495+ expect ( scrubbed ) . toEqual ( [
496+ {
497+ role : 'model' ,
498+ parts : [ { text : 'response' } ] ,
499+ } ,
500+ ] ) ;
501+ } ) ;
502+
503+ it ( 'should completely filter out Content objects that have no parts left after thought scrubbing and coalesce adjacent turns of the same role' , ( ) => {
504+ const contents : Content [ ] = [
505+ {
506+ role : 'user' ,
507+ parts : [ { text : 'Hello' } ] ,
508+ } ,
509+ {
510+ role : 'model' ,
511+ parts : [ { text : 'thought' , thought : true } as unknown as Part ] ,
512+ } ,
513+ {
514+ role : 'user' ,
515+ parts : [ { text : 'How are you?' } ] ,
516+ } ,
517+ ] ;
518+ const scrubbed = scrubContents ( contents ) ;
519+ expect ( scrubbed ) . toEqual ( [
520+ {
521+ role : 'user' ,
522+ parts : [ { text : 'Hello' } , { text : 'How are you?' } ] ,
523+ } ,
524+ ] ) ;
525+ } ) ;
526+
527+ it ( 'should coalesce adjacent turns of the same role when no filtration occurs' , ( ) => {
528+ const contents : Content [ ] = [
529+ {
530+ role : 'user' ,
531+ parts : [ { text : 'Part 1' } ] ,
532+ } ,
533+ {
534+ role : 'user' ,
535+ parts : [ { text : 'Part 2' } ] ,
536+ } ,
537+ ] ;
538+ const scrubbed = scrubContents ( contents ) ;
539+ expect ( scrubbed ) . toEqual ( [
540+ {
541+ role : 'user' ,
542+ parts : [ { text : 'Part 1' } , { text : 'Part 2' } ] ,
543+ } ,
544+ ] ) ;
545+ } ) ;
546+ } ) ;
547+
548+ describe ( 'scrubHistory' , ( ) => {
549+ it ( 'should scrub non-standard fields and filter empty turns in history' , ( ) => {
550+ const history : HistoryTurn [ ] = [
551+ {
552+ id : '1' ,
553+ content : {
554+ role : 'user' ,
555+ parts : [ { text : 'Hello' , customField : 'ignored' } as unknown as Part ] ,
556+ } ,
557+ } ,
558+ {
559+ id : '2' ,
560+ content : {
561+ role : 'model' ,
562+ parts : [ { text : 'thought' , thought : true } as unknown as Part ] ,
563+ } ,
564+ } ,
565+ {
566+ id : '3' ,
567+ content : {
568+ role : 'user' ,
569+ parts : [ { text : 'World' } ] ,
570+ } ,
571+ } ,
572+ ] ;
573+
574+ const scrubbed = scrubHistory ( history ) ;
575+ expect ( scrubbed . length ) . toBe ( 1 ) ; // Since user turns are coalesced (Turn 1 + Turn 3) and Turn 2 is removed because it has 0 parts
576+ expect ( scrubbed [ 0 ] . content . parts ) . toEqual ( [
577+ { text : 'Hello' } ,
578+ { text : 'World' } ,
579+ ] ) ;
580+ } ) ;
378581} ) ;
0 commit comments