@@ -19,7 +19,9 @@ package summarization
1919import (
2020 "context"
2121 "errors"
22+ "strings"
2223 "testing"
24+ "unicode/utf8"
2325
2426 "github.com/stretchr/testify/assert"
2527
@@ -212,6 +214,24 @@ func TestPreserveSkillsConfigCheck(t *testing.T) {
212214 err := c .check ()
213215 assert .NoError (t , err )
214216 })
217+
218+ t .Run ("negative max tokens per skill" , func (t * testing.T ) {
219+ c := & PreserveSkillsConfig {
220+ MaxTokensPerSkill : ptr (- 1 ),
221+ }
222+ err := c .check ()
223+ assert .Error (t , err )
224+ assert .Contains (t , err .Error (), "MaxTokensPerSkill must be non-negative" )
225+ })
226+
227+ t .Run ("negative skills token budget" , func (t * testing.T ) {
228+ c := & PreserveSkillsConfig {
229+ SkillsTokenBudget : ptr (- 1 ),
230+ }
231+ err := c .check ()
232+ assert .Error (t , err )
233+ assert .Contains (t , err .Error (), "SkillsTokenBudget must be non-negative" )
234+ })
215235}
216236
217237func TestPreserveSkillsViaBuilder (t * testing.T ) {
@@ -434,4 +454,128 @@ func TestBuildPreservedSkillsText(t *testing.T) {
434454 assert .NotContains (t , text , "skill2" )
435455 assert .NotContains (t , text , "c2" )
436456 })
457+
458+ t .Run ("per skill token limit truncates large skills" , func (t * testing.T ) {
459+ // estimateTokenCount = (len+3)/4
460+ // "short" = 5 chars → 2 tokens
461+ // strings.Repeat("x", 100) = 100 chars → 25 tokens
462+ largeContent := strings .Repeat ("x" , 100 )
463+ messages := []adk.Message {
464+ {
465+ Role : schema .Assistant ,
466+ ToolCalls : []schema.ToolCall {
467+ {ID : "call_1" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "small"}` }},
468+ {ID : "call_2" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "large"}` }},
469+ },
470+ },
471+ {Role : schema .Tool , ToolCallID : "call_1" , Content : "short" },
472+ {Role : schema .Tool , ToolCallID : "call_2" , Content : largeContent },
473+ }
474+
475+ // MaxTokensPerSkill=10: "short"→2 tokens (ok), largeContent→25 tokens (truncated)
476+ text , err := buildPreservedSkillsText (ctx , messages , & PreserveSkillsConfig {
477+ MaxSkills : ptr (10 ),
478+ MaxTokensPerSkill : ptr (10 ),
479+ SkillToolName : "load_skill" ,
480+ })
481+ assert .NoError (t , err )
482+ // small skill preserved as-is
483+ assert .Contains (t , text , "small" )
484+ assert .Contains (t , text , "short" )
485+ // large skill is truncated, not dropped — name still present, full content gone
486+ assert .Contains (t , text , "large" )
487+ assert .NotContains (t , text , largeContent )
488+ assert .Contains (t , text , "skill content truncated for compaction" )
489+ })
490+
491+ t .Run ("total token budget drops excess skills" , func (t * testing.T ) {
492+ // Each content is 40 chars → (40+3)/4 = 10 tokens
493+ content := strings .Repeat ("a" , 40 )
494+ messages := []adk.Message {
495+ {
496+ Role : schema .Assistant ,
497+ ToolCalls : []schema.ToolCall {
498+ {ID : "call_1" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "skill1"}` }},
499+ {ID : "call_2" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "skill2"}` }},
500+ {ID : "call_3" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "skill3"}` }},
501+ },
502+ },
503+ {Role : schema .Tool , ToolCallID : "call_1" , Content : content },
504+ {Role : schema .Tool , ToolCallID : "call_2" , Content : content },
505+ {Role : schema .Tool , ToolCallID : "call_3" , Content : content },
506+ }
507+
508+ // Budget=15: skill3=10 tokens fits, skill2=10 tokens → 10+10=20 > 15, stop.
509+ text , err := buildPreservedSkillsText (ctx , messages , & PreserveSkillsConfig {
510+ MaxSkills : ptr (10 ),
511+ SkillsTokenBudget : ptr (15 ),
512+ SkillToolName : "load_skill" ,
513+ })
514+ assert .NoError (t , err )
515+ assert .Contains (t , text , "skill3" )
516+ assert .NotContains (t , text , "skill1" )
517+ assert .NotContains (t , text , "skill2" )
518+ })
519+
520+ t .Run ("token budget and per-skill limit combined" , func (t * testing.T ) {
521+ // s1: 16 chars → 4 tokens
522+ // s2: 200 chars → 50 tokens (exceeds per-skill limit of 20, gets truncated)
523+ // s3: 24 chars → 6 tokens
524+ // s4: 24 chars → 6 tokens
525+ messages := []adk.Message {
526+ {
527+ Role : schema .Assistant ,
528+ ToolCalls : []schema.ToolCall {
529+ {ID : "call_1" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "s1"}` }},
530+ {ID : "call_2" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "s2"}` }},
531+ {ID : "call_3" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "s3"}` }},
532+ {ID : "call_4" , Function : schema.FunctionCall {Name : "load_skill" , Arguments : `{"skill": "s4"}` }},
533+ },
534+ },
535+ {Role : schema .Tool , ToolCallID : "call_1" , Content : strings .Repeat ("a" , 16 )},
536+ {Role : schema .Tool , ToolCallID : "call_2" , Content : strings .Repeat ("b" , 200 )},
537+ {Role : schema .Tool , ToolCallID : "call_3" , Content : strings .Repeat ("c" , 24 )},
538+ {Role : schema .Tool , ToolCallID : "call_4" , Content : strings .Repeat ("d" , 24 )},
539+ }
540+
541+ // Per-skill limit: 20 (s2 with 50 tokens is truncated to 20)
542+ // Budget: 30 (from most recent: s4=6, s3=6, s2=20, total=32 > 30, so s2 cannot fit)
543+ // Result: s4 and s3 preserved
544+ text , err := buildPreservedSkillsText (ctx , messages , & PreserveSkillsConfig {
545+ MaxSkills : ptr (10 ),
546+ MaxTokensPerSkill : ptr (20 ),
547+ SkillsTokenBudget : ptr (30 ),
548+ SkillToolName : "load_skill" ,
549+ })
550+ assert .NoError (t , err )
551+ assert .Contains (t , text , "s3" )
552+ assert .Contains (t , text , "s4" )
553+ assert .NotContains (t , text , "\" s1\" " )
554+ assert .NotContains (t , text , "\" s2\" " )
555+ })
556+
557+ t .Run ("truncated skill content preserves only prefix" , func (t * testing.T ) {
558+ // Use a long content and generous maxTokens so the prefix is clearly visible.
559+ content := strings .Repeat ("abcdefghij" , 100 ) // 1000 bytes → 250 tokens
560+ // maxTokens=125 → targetBytes = 500, minus ~101 marker bytes → ~399 prefix bytes
561+ truncated := truncateSkillContent (content , 125 )
562+ assert .True (t , strings .HasPrefix (truncated , "abcdefghij" )) // prefix preserved
563+ assert .Contains (t , truncated , "skill content truncated for compaction" )
564+ assert .NotEqual (t , content , truncated )
565+ // Ends with marker, not with original content suffix
566+ assert .True (t , strings .HasSuffix (truncated , "]" ))
567+ // No suffix from original content
568+ assert .False (t , strings .HasSuffix (truncated , "abcdefghij]" ))
569+ })
570+
571+ t .Run ("truncated multibyte content does not produce invalid utf8" , func (t * testing.T ) {
572+ // Each Chinese char is 3 bytes. 334 chars = 1002 bytes → 251 tokens
573+ content := strings .Repeat ("中" , 334 )
574+ // maxTokens=125 → targetBytes=500, minus marker ~101 bytes → ~399 bytes
575+ // 399 / 3 = 133 full Chinese chars, no partial rune
576+ truncated := truncateSkillContent (content , 125 )
577+ assert .True (t , utf8 .ValidString (truncated ))
578+ assert .True (t , strings .HasPrefix (truncated , "中中中" ))
579+ assert .Contains (t , truncated , "skill content truncated for compaction" )
580+ })
437581}
0 commit comments