@@ -9,6 +9,7 @@ private actor CompactionMockLLMClient: LLMClient {
99 private let responses : [ AssistantMessage ]
1010 private var callIndex : Int = 0
1111 private( set) var allCapturedMessages : [ [ ChatMessage ] ] = [ ]
12+ private( set) var generateCallCount : Int = 0
1213 private let failSummarization : Bool
1314
1415 init (
@@ -25,6 +26,7 @@ private actor CompactionMockLLMClient: LLMClient {
2526 messages: [ ChatMessage ] , tools _: [ ToolDefinition ] ,
2627 responseFormat _: ResponseFormat ? , requestContext _: RequestContext ?
2728 ) async throws -> AssistantMessage {
29+ generateCallCount += 1
2830 if failSummarization, case let . user( text) = messages. last,
2931 text. contains ( " CONTEXT CHECKPOINT " ) {
3032 throw AgentError . llmError ( . other( " Summarization failed " ) )
@@ -269,6 +271,120 @@ struct CompactionTriggerTests {
269271// MARK: - Compaction Fallback Tests
270272
271273struct CompactionFallbackTests {
274+ @Test
275+ func circuitBreakerSkipsSummarizationAfterConsecutiveFailures( ) async {
276+ let client = CompactionMockLLMClient (
277+ responses: [ ] , contextWindowSize: 1000 , failSummarization: true
278+ )
279+ var compactor = ContextCompactor (
280+ client: client,
281+ toolDefinitions: [ ] ,
282+ configuration: AgentConfiguration ( maxMessages: 20 , compactionThreshold: 0.5 )
283+ )
284+ var messages : [ ChatMessage ] = [
285+ . user( " Hello " ) ,
286+ . assistant( AssistantMessage ( content: " " , toolCalls: [
287+ ToolCall ( id: " call_1 " , name: " search " , arguments: " {} " ) ,
288+ ] ) ) ,
289+ . tool( id: " call_1 " , name: " search " , content: String ( repeating: " x " , count: 10 ) ) ,
290+ . assistant( AssistantMessage ( content: " Done " ) ) ,
291+ ]
292+ var usage = TokenUsage ( )
293+
294+ for _ in 0 ..< 3 {
295+ await compactor. compactOrTruncateIfNeeded (
296+ & messages, lastTotalTokens: 900 , totalUsage: & usage
297+ )
298+ }
299+ let callsAfterTripping = await client. generateCallCount
300+ #expect( callsAfterTripping == 3 )
301+
302+ await compactor. compactOrTruncateIfNeeded (
303+ & messages, lastTotalTokens: 900 , totalUsage: & usage
304+ )
305+ let callsAfterSkip = await client. generateCallCount
306+ #expect( callsAfterSkip == 3 )
307+ }
308+
309+ @Test
310+ func circuitBreakerResetsOnSuccess( ) async {
311+ var compactor = ContextCompactor (
312+ client: CompactionMockLLMClient (
313+ responses: [
314+ AssistantMessage ( content: " Summary. " , tokenUsage: TokenUsage ( input: 50 , output: 100 ) ) ,
315+ ] ,
316+ contextWindowSize: 1000 , failSummarization: false
317+ ) ,
318+ toolDefinitions: [ ] ,
319+ configuration: AgentConfiguration ( compactionThreshold: 0.5 )
320+ )
321+ var messages : [ ChatMessage ] = [
322+ . user( " Hello " ) ,
323+ . assistant( AssistantMessage ( content: " Done " ) ) ,
324+ ]
325+ var usage = TokenUsage ( )
326+
327+ let result = await compactor. compactOrTruncateIfNeeded (
328+ & messages, lastTotalTokens: 900 , totalUsage: & usage
329+ )
330+ #expect( result)
331+ #expect( hasBridge ( messages) )
332+ }
333+
334+ @Test
335+ func circuitBreakerResetsAfterPruningSuccess( ) async {
336+ let client = CompactionMockLLMClient (
337+ responses: [ ] , contextWindowSize: 1000 , failSummarization: true
338+ )
339+ var compactor = ContextCompactor (
340+ client: client,
341+ toolDefinitions: [ ] ,
342+ configuration: AgentConfiguration ( maxMessages: 20 , compactionThreshold: 0.5 )
343+ )
344+ var summarizationMessages : [ ChatMessage ] = [
345+ . user( " Hello " ) ,
346+ . assistant( AssistantMessage ( content: " " , toolCalls: [
347+ ToolCall ( id: " call_1 " , name: " search " , arguments: " {} " ) ,
348+ ] ) ) ,
349+ . tool( id: " call_1 " , name: " search " , content: String ( repeating: " x " , count: 10 ) ) ,
350+ . assistant( AssistantMessage ( content: " Done " ) ) ,
351+ ]
352+ var pruningMessages : [ ChatMessage ] = [
353+ . user( " Hello " ) ,
354+ . assistant( AssistantMessage ( content: " " , toolCalls: [
355+ ToolCall ( id: " call_2 " , name: " read_file " , arguments: " {} " ) ,
356+ ] ) ) ,
357+ . tool( id: " call_2 " , name: " read_file " , content: String ( repeating: " x " , count: 5000 ) ) ,
358+ . assistant( AssistantMessage ( content: " Done " ) ) ,
359+ ]
360+ var usage = TokenUsage ( )
361+
362+ for _ in 0 ..< 2 {
363+ await compactor. compactOrTruncateIfNeeded (
364+ & summarizationMessages, lastTotalTokens: 900 , totalUsage: & usage
365+ )
366+ }
367+ #expect( await client. generateCallCount == 2 )
368+
369+ let pruned = await compactor. compactOrTruncateIfNeeded (
370+ & pruningMessages, lastTotalTokens: 900 , totalUsage: & usage
371+ )
372+ #expect( pruned)
373+ #expect( await client. generateCallCount == 2 )
374+
375+ for _ in 0 ..< 3 {
376+ await compactor. compactOrTruncateIfNeeded (
377+ & summarizationMessages, lastTotalTokens: 900 , totalUsage: & usage
378+ )
379+ }
380+ #expect( await client. generateCallCount == 5 )
381+
382+ await compactor. compactOrTruncateIfNeeded (
383+ & summarizationMessages, lastTotalTokens: 900 , totalUsage: & usage
384+ )
385+ #expect( await client. generateCallCount == 5 )
386+ }
387+
272388 @Test
273389 func compactionFallsBackToTruncationOnError( ) async throws {
274390 let client = CompactionMockLLMClient (
@@ -527,6 +643,67 @@ struct ObservationPruningTests {
527643 }
528644}
529645
646+ // MARK: - Media Stripping Tests
647+
648+ struct MediaStrippingTests {
649+ @Test
650+ func summarizationStripsMediaFromMultimodalMessages( ) async throws {
651+ let client = CompactionMockLLMClient (
652+ responses: [
653+ AssistantMessage ( content: " Summary. " , tokenUsage: TokenUsage ( input: 50 , output: 100 ) ) ,
654+ ]
655+ )
656+ let compactor = ContextCompactor (
657+ client: client, toolDefinitions: [ ] , configuration: AgentConfiguration ( )
658+ )
659+ let messages : [ ChatMessage ] = [
660+ . user( [
661+ . text( " Describe this " ) ,
662+ . image( data: Data ( repeating: 0xFF , count: 1000 ) , mimeType: " image/png " ) ,
663+ . audio( data: Data ( repeating: 0xAA , count: 500 ) , format: . mp3) ,
664+ . video( data: Data ( repeating: 0xBB , count: 500 ) , mimeType: " video/mp4 " ) ,
665+ . pdf( data: Data ( repeating: 0xCC , count: 500 ) ) ,
666+ ] ) ,
667+ . assistant( AssistantMessage ( content: " I see an image. " ) ) ,
668+ ]
669+ _ = try await compactor. summarize ( messages)
670+
671+ let captured = await client. allCapturedMessages
672+ guard case let . userMultimodal( parts) = captured [ 0 ] [ 0 ] else {
673+ Issue . record ( " Expected userMultimodal " ) ; return
674+ }
675+ #expect( parts. count == 5 )
676+ #expect( parts. allSatisfy { if case . text = $0 { true } else { false } } )
677+ #expect( parts. contains { if case let . text( text) = $0 { text == " [image] " } else { false } } )
678+ #expect( parts. contains { if case let . text( text) = $0 { text == " [audio] " } else { false } } )
679+ #expect( parts. contains { if case let . text( text) = $0 { text == " [video] " } else { false } } )
680+ #expect( parts. contains { if case let . text( text) = $0 { text == " [PDF] " } else { false } } )
681+ }
682+
683+ @Test
684+ func summarizationPreservesTextOnlyMessages( ) async throws {
685+ let client = CompactionMockLLMClient (
686+ responses: [
687+ AssistantMessage ( content: " Summary. " , tokenUsage: TokenUsage ( input: 50 , output: 100 ) ) ,
688+ ]
689+ )
690+ let compactor = ContextCompactor (
691+ client: client, toolDefinitions: [ ] , configuration: AgentConfiguration ( )
692+ )
693+ let messages : [ ChatMessage ] = [
694+ . user( " Plain text message " ) ,
695+ . assistant( AssistantMessage ( content: " Response " ) ) ,
696+ ]
697+ _ = try await compactor. summarize ( messages)
698+
699+ let captured = await client. allCapturedMessages
700+ guard case let . user( text) = captured [ 0 ] [ 0 ] else {
701+ Issue . record ( " Expected user string message " ) ; return
702+ }
703+ #expect( text == " Plain text message " )
704+ }
705+ }
706+
530707// MARK: - Tool Result Truncation Tests
531708
532709struct ToolResultTruncationTests {
@@ -578,8 +755,8 @@ struct ToolResultTruncationTests {
578755 + String( repeating: " Z " , count: 100 )
579756 let config = AgentConfiguration ( maxToolResultCharacters: 60 )
580757 let truncated = ContextCompactor . truncateToolResult ( content, configuration: config)
581- #expect( truncated. hasPrefix ( String ( repeating: " A " , count: 19 ) ) )
582- #expect( truncated. hasSuffix ( String ( repeating: " Z " , count: 19 ) ) )
758+ #expect( truncated. hasPrefix ( String ( repeating: " A " , count: 22 ) ) )
759+ #expect( truncated. hasSuffix ( String ( repeating: " Z " , count: 16 ) ) )
583760 #expect( truncated. count <= 60 )
584761 #expect( truncated. contains ( " truncated " ) )
585762 }
0 commit comments