11/** biome-ignore-all lint/style/noNonNullAssertion: fine for tests */
22/** biome-ignore-all lint/suspicious/noExplicitAny: fine for tests */
33import { describe , expect , test } from "bun:test" ;
4+ import { APICallError } from "ai" ;
45import {
56 applyCompactionToMessages ,
67 buildCompactionRequestMessage ,
78 COMPACT_CONVERSATION_TOOL_NAME ,
89 COMPACTION_MARKER_TOOL_NAME ,
910 countCompactionMarkers ,
11+ maxConsecutiveCompactionAttempts ,
1012 createCompactionMarkerPart ,
1113 createCompactionTool ,
14+ findAPICallError ,
1215 findCompactionSummary ,
1316 isOutOfContextError ,
17+ MAX_CONSECUTIVE_COMPACTION_ATTEMPTS ,
1418} from "./compaction" ;
1519import type { Message } from "./types" ;
1620
@@ -55,62 +59,77 @@ function summaryMsg(
5559}
5660
5761describe ( "isOutOfContextError" , ( ) => {
58- test ( "returns true for Anthropic context limit errors" , ( ) => {
59- expect ( isOutOfContextError ( new Error ( "max_tokens_exceeded" ) ) ) . toBe ( true ) ;
62+ const createApiError = ( message : string ) =>
63+ new APICallError ( {
64+ message,
65+ url : "https://api.example.com" ,
66+ requestBodyValues : { } ,
67+ statusCode : 400 ,
68+ } ) ;
69+
70+ test ( "returns true for APICallError with context limit message" , ( ) => {
6071 expect (
61- isOutOfContextError ( new Error ( "The context window has been exceeded" ) )
72+ isOutOfContextError (
73+ createApiError ( "Input is too long for requested model" )
74+ )
6275 ) . toBe ( true ) ;
63- } ) ;
64-
65- test ( "returns true for OpenAI context_length_exceeded errors" , ( ) => {
66- expect ( isOutOfContextError ( new Error ( "context_length_exceeded" ) ) ) . toBe (
76+ expect ( isOutOfContextError ( createApiError ( "context_length_exceeded" ) ) ) . toBe (
6777 true
6878 ) ;
6979 } ) ;
7080
71- test ( "returns true for generic token limit exceeded messages" , ( ) => {
72- expect ( isOutOfContextError ( new Error ( "token limit exceeded" ) ) ) . toBe ( true ) ;
73- expect (
74- isOutOfContextError ( new Error ( "Token limit has been exceeded" ) )
75- ) . toBe ( true ) ;
76- expect ( isOutOfContextError ( new Error ( "maximum tokens reached" ) ) ) . toBe ( true ) ;
81+ test ( "returns true for APICallError in cause chain" , ( ) => {
82+ const apiError = createApiError ( "max_tokens_exceeded" ) ;
83+ const wrapper = new Error ( "Gateway error" ) ;
84+ ( wrapper as { cause ?: unknown } ) . cause = apiError ;
85+ expect ( isOutOfContextError ( wrapper ) ) . toBe ( true ) ;
7786 } ) ;
7887
79- test ( "returns true for context window errors" , ( ) => {
80- expect ( isOutOfContextError ( new Error ( "context window exceeded" ) ) ) . toBe (
81- true
82- ) ;
83- expect ( isOutOfContextError ( new Error ( "context length exceeded" ) ) ) . toBe (
84- true
88+ test ( "returns false for APICallError with unrelated message" , ( ) => {
89+ expect ( isOutOfContextError ( createApiError ( "authentication failed" ) ) ) . toBe (
90+ false
8591 ) ;
8692 } ) ;
8793
88- test ( "returns true for input too long errors" , ( ) => {
89- expect ( isOutOfContextError ( new Error ( "input is too long" ) ) ) . toBe ( true ) ;
90- expect ( isOutOfContextError ( new Error ( "prompt is too long" ) ) ) . toBe ( true ) ;
94+ test ( "returns false for non-APICallError even if message matches pattern" , ( ) => {
95+ expect ( isOutOfContextError ( new Error ( "context_length_exceeded" ) ) ) . toBe (
96+ false
97+ ) ;
98+ expect ( isOutOfContextError ( "input too long" ) ) . toBe ( false ) ;
9199 } ) ;
100+ } ) ;
101+
102+ describe ( "findAPICallError" , ( ) => {
103+ const createApiError = ( message : string ) =>
104+ new APICallError ( {
105+ message,
106+ url : "https://api.example.com" ,
107+ requestBodyValues : { } ,
108+ statusCode : 400 ,
109+ } ) ;
92110
93- test ( "returns false for unrelated errors" , ( ) => {
94- expect ( isOutOfContextError ( new Error ( "network error" ) ) ) . toBe ( false ) ;
95- expect ( isOutOfContextError ( new Error ( "authentication failed" ) ) ) . toBe ( false ) ;
96- expect ( isOutOfContextError ( new Error ( "rate limit exceeded" ) ) ) . toBe ( false ) ;
111+ test ( "returns the APICallError when provided directly" , ( ) => {
112+ const error = createApiError ( "test" ) ;
113+ expect ( findAPICallError ( error ) ) . toBe ( error ) ;
97114 } ) ;
98115
99- test ( "handles string messages" , ( ) => {
100- expect ( isOutOfContextError ( "token limit exceeded" ) ) . toBe ( true ) ;
101- expect ( isOutOfContextError ( "some other error" ) ) . toBe ( false ) ;
116+ test ( "returns APICallError from single-level cause" , ( ) => {
117+ const apiError = createApiError ( "test" ) ;
118+ const wrapper = new Error ( "wrapper" ) ;
119+ ( wrapper as { cause ?: unknown } ) . cause = apiError ;
120+ expect ( findAPICallError ( wrapper ) ) . toBe ( apiError ) ;
102121 } ) ;
103122
104- test ( "handles objects with message property" , ( ) => {
105- expect ( isOutOfContextError ( { message : "token limit exceeded" } ) ) . toBe ( true ) ;
106- expect ( isOutOfContextError ( { message : "some other error" } ) ) . toBe ( false ) ;
123+ test ( "returns APICallError from deep cause chain" , ( ) => {
124+ const apiError = createApiError ( "test" ) ;
125+ const wrapper = { cause : { cause : apiError } } ;
126+ expect ( findAPICallError ( wrapper ) ) . toBe ( apiError ) ;
107127 } ) ;
108128
109- test ( "returns false for non-error values" , ( ) => {
110- expect ( isOutOfContextError ( null ) ) . toBe ( false ) ;
111- expect ( isOutOfContextError ( undefined ) ) . toBe ( false ) ;
112- expect ( isOutOfContextError ( 123 ) ) . toBe ( false ) ;
113- expect ( isOutOfContextError ( { } ) ) . toBe ( false ) ;
129+ test ( "returns null when no APICallError present" , ( ) => {
130+ expect ( findAPICallError ( new Error ( "other" ) ) ) . toBeNull ( ) ;
131+ expect ( findAPICallError ( "string" ) ) . toBeNull ( ) ;
132+ expect ( findAPICallError ( null ) ) . toBeNull ( ) ;
114133 } ) ;
115134} ) ;
116135
@@ -285,6 +304,37 @@ describe("countCompactionMarkers", () => {
285304 } ) ;
286305} ) ;
287306
307+ describe ( "maxConsecutiveCompactionAttempts" , ( ) => {
308+ test ( "counts consecutive assistant compaction attempts" , ( ) => {
309+ const messages : Message [ ] = [
310+ userMsg ( "1" , "Hello" ) ,
311+ summaryMsg ( "summary-1" , "Summary output 1" ) ,
312+ summaryMsg ( "summary-2" , "Summary output 2" ) ,
313+ ] ;
314+
315+ expect ( maxConsecutiveCompactionAttempts ( messages ) ) . toBe ( 2 ) ;
316+ } ) ;
317+
318+ test ( "does not count non-consecutive compaction attempts" , ( ) => {
319+ const messages : Message [ ] = [
320+ summaryMsg ( "summary-1" , "First summary" ) ,
321+ userMsg ( "1" , "Hello" ) ,
322+ summaryMsg ( "summary-2" , "Second summary" ) ,
323+ ] ;
324+
325+ expect ( maxConsecutiveCompactionAttempts ( messages ) ) . toBe ( 1 ) ;
326+ } ) ;
327+
328+ test ( "stops at non-compaction assistant message" , ( ) => {
329+ const messages : Message [ ] = [
330+ markerMsg ( "marker1" ) ,
331+ assistantMsg ( "assistant" , "Normal reply" ) ,
332+ ] ;
333+
334+ expect ( maxConsecutiveCompactionAttempts ( messages ) ) . toBe ( 0 ) ;
335+ } ) ;
336+ } ) ;
337+
288338describe ( "buildCompactionRequestMessage" , ( ) => {
289339 test ( "creates user message with correct role" , ( ) => {
290340 const message = buildCompactionRequestMessage ( ) ;
@@ -311,6 +361,20 @@ describe("applyCompactionToMessages", () => {
311361 expect ( result ) . toEqual ( messages ) ;
312362 } ) ;
313363
364+ test ( "throws when consecutive compaction attempts hit the limit" , ( ) => {
365+ const attempts = MAX_CONSECUTIVE_COMPACTION_ATTEMPTS + 1 ;
366+ const messages : Message [ ] = [
367+ userMsg ( "1" , "Hello" ) ,
368+ ...Array . from ( { length : attempts } , ( _ , idx ) =>
369+ summaryMsg ( `summary-${ idx } ` , `Summary ${ idx } ` )
370+ ) ,
371+ ] ;
372+
373+ expect ( ( ) => applyCompactionToMessages ( messages ) ) . toThrow (
374+ / C o m p a c t i o n l o o p d e t e c t e d /
375+ ) ;
376+ } ) ;
377+
314378 test ( "excludes correct number of messages based on marker count" , ( ) => {
315379 const messages : Message [ ] = [
316380 userMsg ( "1" , "Message 1" ) ,
@@ -582,6 +646,10 @@ describe("applyCompactionToMessages", () => {
582646 userMsg ( "3" , "Third message" ) ,
583647 userMsg ( "4" , "Fourth message" ) ,
584648 markerMsg ( "marker1" ) ,
649+ assistantMsg (
650+ "assistant-buffer" ,
651+ "Normal reply between compaction attempts"
652+ ) ,
585653 markerMsg ( "marker2" ) ,
586654 userMsg ( "interrupted" , "User interrupted compaction with this message" ) ,
587655 markerMsg ( "marker3" ) ,
0 commit comments