@@ -27,7 +27,7 @@ const withStore = <A, E, R>(
2727 } )
2828
2929describe ( "ToolOutput" , ( ) => {
30- it . live ( "writes oversized text and returns a bounded preview" , ( ) =>
30+ it . live ( "writes oversized text and returns a bounded head+tail preview" , ( ) =>
3131 withStore (
3232 ( service , fs ) =>
3333 Effect . gen ( function * ( ) {
@@ -40,50 +40,180 @@ describe("ToolOutput", () => {
4040 if ( typeof outputPath !== "string" ) return
4141 expect ( yield * fs . readFileString ( outputPath ) ) . toBe ( "one\ntwo\nthree" )
4242 expect ( result . content ) . toEqual ( [
43- { type : "text" , text : "one\ntwo" } ,
44- { type : "text" , text : `[showing lines 1-2 of 3; full output saved to ${ outputPath } ]` } ,
43+ {
44+ type : "text" ,
45+ text : `one\n[showing lines 1-1 and 3-3 of 3; full output saved to ${ outputPath } ]\nthree` ,
46+ } ,
4547 ] )
4648 } ) ,
4749 { maxLines : 2 , maxBytes : 1_000 } ,
4850 ) ,
4951 )
5052
51- it . live ( "reports lines shown under the byte limit" , ( ) =>
53+ it . live ( "keeps the tail when only the tail fits the byte limit" , ( ) =>
5254 withStore (
5355 ( output ) =>
5456 Effect . gen ( function * ( ) {
5557 const result = yield * output . truncate ( { content : [ { type : "text" , text : "one\ntwo" } ] } )
5658 expect ( result . content ) . toEqual ( [
57- { type : "text" , text : "one" } ,
59+ { type : "text" , text : expect . stringMatching ( / ^ \[ s h o w i n g l i n e s 2 - 2 o f 2 ; f u l l o u t p u t s a v e d t o .+ \] \n t w o $ / ) } ,
60+ ] )
61+ } ) ,
62+ { maxLines : 100 , maxBytes : 5 } ,
63+ ) ,
64+ )
65+
66+ it . live ( "keeps the head and tail within the line limit" , ( ) =>
67+ withStore (
68+ ( output ) =>
69+ Effect . gen ( function * ( ) {
70+ const lines = Array . from ( { length : 100 } , ( _ , index ) => `l${ index + 1 } ` )
71+ const result = yield * output . truncate ( { content : [ { type : "text" , text : lines . join ( "\n" ) } ] } )
72+ const outputPath = result . metadata ?. outputPath
73+ expect ( result . content ) . toEqual ( [
5874 {
5975 type : "text" ,
60- text : expect . stringMatching ( / ^ \ [s h o w i n g l i n e s 1 - 1 o f 2 ; f u l l o u t p u t s a v e d t o . + \] $ / ) ,
76+ text : ` ${ lines . slice ( 0 , 5 ) . join ( "\n" ) } \n [showing lines 1-5 and 96-100 of 100 ; full output saved to ${ outputPath } ]\n ${ lines . slice ( - 5 ) . join ( "\n" ) } ` ,
6177 } ,
6278 ] )
79+ const text = result . content [ 0 ] ?. type === "text" ? result . content [ 0 ] . text : ""
80+ expect ( text . split ( "\n" ) . filter ( ( line ) => ! line . startsWith ( "[showing " ) ) ) . toHaveLength ( 10 )
6381 } ) ,
64- { maxLines : 100 , maxBytes : 5 } ,
82+ { maxLines : 10 , maxBytes : 1_000_000 } ,
6583 ) ,
6684 )
6785
68- it . live ( "preserves mixed content ordering" , ( ) =>
86+ it . live ( "reports only the head when the line budget has no tail room" , ( ) =>
87+ withStore (
88+ ( output ) =>
89+ Effect . gen ( function * ( ) {
90+ const result = yield * output . truncate ( { content : [ { type : "text" , text : "short\nomitted" } ] } )
91+ expect ( result . content ) . toEqual ( [
92+ {
93+ type : "text" ,
94+ text : expect . stringMatching ( / ^ s h o r t \n \[ s h o w i n g l i n e s 1 - 1 o f 2 ; f u l l o u t p u t s a v e d t o .+ \] $ / ) ,
95+ } ,
96+ ] )
97+ } ) ,
98+ { maxLines : 1 , maxBytes : 1_000 } ,
99+ ) ,
100+ )
101+
102+ it . live ( "keeps multibyte tails intact" , ( ) =>
103+ withStore (
104+ ( output ) =>
105+ Effect . gen ( function * ( ) {
106+ const result = yield * output . truncate ( { content : [ { type : "text" , text : "开头\n中间\n结尾🚀" } ] } )
107+ const text = result . content [ 0 ] ?. type === "text" ? result . content [ 0 ] . text : ""
108+ expect ( text ) . toMatch ( / ^ 开 头 \n \[ s h o w i n g l i n e s 1 - 1 a n d 3 - 3 o f 3 ; f u l l o u t p u t s a v e d t o .+ \] \n 结 尾 🚀 $ / )
109+ expect ( text ) . not . toContain ( "\uFFFD" )
110+ } ) ,
111+ { maxLines : 2 , maxBytes : 1_000 } ,
112+ ) ,
113+ )
114+
115+ it . live ( "keeps attachments where they were in the content" , ( ) =>
69116 withStore (
70117 ( output ) =>
71118 Effect . gen ( function * ( ) {
72119 const file = { type : "file" as const , uri : "file:///image.png" , mime : "image/png" }
73120 const result = yield * output . truncate ( {
74121 content : [ { type : "text" , text : "before" } , file , { type : "text" , text : "after\nomitted" } ] ,
75122 } )
123+ const outputPath = result . metadata ?. outputPath
76124 expect ( result . content ) . toEqual ( [
77- { type : "text" , text : " before" } ,
125+ { type : "text" , text : ` before\n[showing lines 1-1 and 3-3 of 3; full output saved to ${ outputPath } ]` } ,
78126 file ,
79- { type : "text" , text : "after" } ,
80- { type : "text" , text : expect . stringMatching ( / ^ \[ s h o w i n g l i n e s 1 - 2 o f 3 ; f u l l o u t p u t s a v e d t o / ) } ,
127+ { type : "text" , text : "omitted" } ,
81128 ] )
82129 } ) ,
83130 { maxLines : 2 , maxBytes : 1_000 } ,
84131 ) ,
85132 )
86133
134+ it . live ( "gives unused tail budget back to the head" , ( ) =>
135+ withStore (
136+ ( output ) =>
137+ Effect . gen ( function * ( ) {
138+ const text = `${ Array . from ( { length : 30 } , ( _ , index ) => `s${ index + 1 } ` ) . join ( "\n" ) } \n${ "x" . repeat ( 2_000 ) } `
139+ const result = yield * output . truncate ( { content : [ { type : "text" , text } ] } )
140+ const preview = result . content [ 0 ] ?. type === "text" ? result . content [ 0 ] . text : ""
141+ expect ( preview ) . toMatch ( / \[ s h o w i n g l i n e s 1 - 1 0 o f 3 1 ; f u l l o u t p u t s a v e d t o .+ \] $ / )
142+ expect ( preview . split ( "\n" ) . filter ( ( line ) => line . startsWith ( "s" ) ) ) . toHaveLength ( 10 )
143+ } ) ,
144+ { maxLines : 10 , maxBytes : 1_000 } ,
145+ ) ,
146+ )
147+
148+ it . live ( "keeps content when the marker alone exceeds the byte limit" , ( ) =>
149+ withStore (
150+ ( output ) =>
151+ Effect . gen ( function * ( ) {
152+ const text = Array . from ( { length : 20 } , ( ) => "x" . repeat ( 20 ) ) . join ( "\n" )
153+ const result = yield * output . truncate ( { content : [ { type : "text" , text } ] } )
154+ const preview = result . content [ 0 ] ?. type === "text" ? result . content [ 0 ] . text : ""
155+ expect ( preview . startsWith ( "x" ) ) . toBe ( true )
156+ expect ( preview ) . toContain ( "[showing lines " )
157+ } ) ,
158+ { maxLines : 10 , maxBytes : 100 } ,
159+ ) ,
160+ )
161+
162+ it . live ( "keeps text lines on the same side of an attachment" , ( ) =>
163+ withStore (
164+ ( output ) =>
165+ Effect . gen ( function * ( ) {
166+ const file = { type : "file" as const , uri : "file:///image.png" , mime : "image/png" }
167+ const result = yield * output . truncate ( {
168+ content : [ { type : "text" , text : "a1\na2\na3\na4\na5\na6" } , file , { type : "text" , text : "b1" } ] ,
169+ } )
170+ const outputPath = result . metadata ?. outputPath
171+ expect ( result . content ) . toEqual ( [
172+ {
173+ type : "text" ,
174+ text : `a1\na2\n[showing lines 1-2 and 6-7 of 7; full output saved to ${ outputPath } ]\na6` ,
175+ } ,
176+ file ,
177+ { type : "text" , text : "b1" } ,
178+ ] )
179+ } ) ,
180+ { maxLines : 4 , maxBytes : 1_000 } ,
181+ ) ,
182+ )
183+
184+ it . live ( "keeps the preview within the byte limit" , ( ) =>
185+ withStore (
186+ ( output ) =>
187+ Effect . gen ( function * ( ) {
188+ const lines = Array . from ( { length : 20 } , ( ) => "x" . repeat ( 100 ) )
189+ const result = yield * output . truncate ( { content : [ { type : "text" , text : lines . join ( "\n" ) } ] } )
190+ const text = result . content [ 0 ] ?. type === "text" ? result . content [ 0 ] . text : ""
191+ expect ( text ) . toContain ( "x" )
192+ expect ( text ) . toContain ( "[showing lines " )
193+ expect ( Buffer . byteLength ( text , "utf-8" ) ) . toBeLessThanOrEqual ( 400 )
194+ } ) ,
195+ { maxLines : 1_000 , maxBytes : 400 } ,
196+ ) ,
197+ )
198+
199+ it . live ( "keeps a leading attachment ahead of the preview" , ( ) =>
200+ withStore (
201+ ( output ) =>
202+ Effect . gen ( function * ( ) {
203+ const file = { type : "file" as const , uri : "file:///image.png" , mime : "image/png" }
204+ const result = yield * output . truncate ( {
205+ content : [ file , { type : "text" , text : "line1\nline2" } ] ,
206+ } )
207+ expect ( result . content [ 0 ] ) . toBe ( file )
208+ expect ( result . content [ 1 ] ) . toEqual ( {
209+ type : "text" ,
210+ text : expect . stringMatching ( / ^ l i n e 1 \n \[ s h o w i n g l i n e s 1 - 1 o f 2 ; f u l l o u t p u t s a v e d t o .+ \] $ / ) ,
211+ } )
212+ } ) ,
213+ { maxLines : 1 , maxBytes : 1_000 } ,
214+ ) ,
215+ )
216+
87217 it . live ( "skips results that report a truncation state" , ( ) =>
88218 withStore ( ( output ) =>
89219 Effect . gen ( function * ( ) {
@@ -124,8 +254,7 @@ describe("ToolOutput", () => {
124254 Effect . gen ( function * ( ) {
125255 const result = yield * output . truncate ( { content : [ { type : "text" , text : "one\n" } ] } )
126256 expect ( result . content ) . toEqual ( [
127- { type : "text" , text : "one" } ,
128- { type : "text" , text : expect . stringMatching ( / ^ \[ s h o w i n g l i n e s 1 - 1 o f 1 ; f u l l o u t p u t s a v e d t o / ) } ,
257+ { type : "text" , text : expect . stringMatching ( / ^ \[ s h o w i n g l i n e s 1 - 1 o f 1 ; f u l l o u t p u t s a v e d t o .+ \] \n o n e $ / ) } ,
129258 ] )
130259 } ) ,
131260 { maxLines : 2 , maxBytes : 3 } ,
0 commit comments