@@ -4,7 +4,12 @@ import { join } from "node:path";
44import { tmpdir } from "node:os" ;
55import type { ToolCall , ToolResult } from "@intx/types/runtime" ;
66
7+ import { createPosixTools } from "@intx/tools-posix" ;
8+
79import { ripgrepPlugin } from "../../src/plugins/ripgrep-plugin.js" ;
10+ import { MAX_RESULT_CHARS } from "../../src/plugins/result-truncation-plugin.js" ;
11+ import { buildCorePosixToolPlugins } from "../../src/agent/posix-tool-plugins.js" ;
12+ import { createPermissionGate } from "../../src/permission/gate.js" ;
813import type { RgChild , SpawnRg } from "../../src/plugins/rg-run.js" ;
914
1015// Repo root derived from this file, not process.cwd(): these cases search real
@@ -32,6 +37,34 @@ const stalledSpawn: SpawnRg = (): RgChild => ({
3237 kill : ( ) => undefined ,
3338} ) ;
3439
40+ // A child whose stdout is scripted directly, bypassing a real `rg` process
41+ // (and its own --max-count filtering) so the byte cap and the line-count cap
42+ // can both be forced to fire on the same run.
43+ function scriptedSpawn ( stdout : string , code : number | null ) : SpawnRg {
44+ return ( ) => {
45+ let onData : ( ( chunk : unknown ) => void ) | undefined ;
46+ let onClose : ( ( code : number | null ) => void ) | undefined ;
47+ const child : RgChild = {
48+ pid : undefined ,
49+ stdout : {
50+ on : ( _event , listener ) => {
51+ onData = listener ;
52+ } ,
53+ } ,
54+ stderr : { on : ( ) => undefined } ,
55+ on : ( ( event : string , listener : ( arg : never ) => void ) => {
56+ if ( event === "close" ) onClose = listener as ( code : number | null ) => void ;
57+ } ) as RgChild [ "on" ] ,
58+ kill : ( ) => undefined ,
59+ } ;
60+ queueMicrotask ( ( ) => {
61+ onData ?.( stdout ) ;
62+ onClose ?.( code ) ;
63+ } ) ;
64+ return child ;
65+ } ;
66+ }
67+
3568async function withTempDir ( run : ( dir : string ) => Promise < void > ) : Promise < void > {
3669 const dir = await mkdtemp ( join ( tmpdir ( ) , "ripgrep-plugin-" ) ) ;
3770 try {
@@ -90,8 +123,7 @@ test("grep returns partial matches when the output byte cap is hit", async () =>
90123 ) ;
91124 expect ( result . isError ) . toBeUndefined ( ) ;
92125 expect ( result . content ) . toContain ( "match line here" ) ;
93- expect ( result . content ) . toContain ( "exceeded 200 bytes" ) ;
94- expect ( result . content ) . toContain ( "narrow path/glob or pattern" ) ;
126+ expect ( String ( result . content ) . length ) . toBeLessThanOrEqual ( 200 ) ;
95127 } ) ;
96128} ) ;
97129
@@ -117,8 +149,85 @@ test("the output byte cap holds when ripgrep is unavailable", async () => {
117149 ) ;
118150 expect ( result . isError ) . toBeUndefined ( ) ;
119151 expect ( result . content ) . toContain ( "match line here" ) ;
120- expect ( result . content ) . toContain ( "exceeded 200 bytes" ) ;
121- expect ( result . content . length ) . toBeLessThan ( 400 ) ;
152+ expect ( String ( result . content ) . length ) . toBeLessThan ( 400 ) ;
153+ } ) ;
154+ } ) ;
155+ } ) ;
156+
157+ // A grep run that both breaches the byte cap (rg-output.ts) and matches more
158+ // lines than max_results (ripgrep-plugin.ts's own count cap) used to stack the
159+ // byte cap's wording on top of the count cap's. The byte cap is silent now, so
160+ // what is left describes the omission the reader cannot otherwise detect: how
161+ // many matches were dropped.
162+ test ( "a grep result that hits both the byte cap and the match-count cap announces the dropped matches once" , async ( ) => {
163+ // 400 matched lines emitted directly, bypassing a real `rg` process so
164+ // nothing upstream of ripgrep-plugin.ts pre-limits the line count.
165+ const result = await run (
166+ { id : "c" , name : "grep" , arguments : { pattern : "match" , path : cwd , max_results : 3 } } ,
167+ { maxOutputBytes : 200 } ,
168+ scriptedSpawn ( "big.txt:1:match line here\n" . repeat ( 400 ) , 0 ) ,
169+ ) ;
170+
171+ expect ( result . isError ) . toBeUndefined ( ) ;
172+ const content = String ( result . content ) ;
173+ expect ( content . split ( "\n" ) . filter ( ( l ) => l . includes ( "match line here" ) ) . length ) . toBe ( 3 ) ;
174+ expect ( ( content . match ( / s h o w i n g f i r s t / g) ?? [ ] ) . length ) . toBe ( 1 ) ;
175+ expect ( content ) . not . toMatch ( / e x c e e d e d \d + b y t e s | t i m e d o u t | \[ o u t p u t t r u n c a t e d / ) ;
176+ } ) ;
177+
178+ // Composed through buildCorePosixToolPlugins, not a hand-assembled pair:
179+ // ripgrepPlugin sits at an earlier array index than resultTruncationPlugin and
180+ // answers grep without calling next, so resultTruncationPlugin never sees a
181+ // grep result. Assembling the two by hand in the other order hides that and
182+ // lets an oversized result reach the model uncapped and unannounced.
183+ async function grepThroughRealChain ( dir : string ) : Promise < string > {
184+ const gate = createPermissionGate ( {
185+ approvals : [ ] ,
186+ interactive : false ,
187+ skipPermissions : true ,
188+ cwd : dir ,
189+ } ) ;
190+ const runner = createPosixTools ( {
191+ cwd : dir ,
192+ plugins : buildCorePosixToolPlugins ( { cwd : dir , permissionGate : gate } ) ,
193+ } ) ;
194+ const result = await runner . run (
195+ { id : "c" , name : "grep" , arguments : { pattern : "line" , path : dir } } ,
196+ new AbortController ( ) . signal ,
197+ ) ;
198+ expect ( result . isError ) . not . toBe ( true ) ;
199+ return String ( result . content ) ;
200+ }
201+
202+ async function writeOversizedHaystack ( dir : string ) : Promise < void > {
203+ const lines = Array . from ( { length : 5000 } , ( _ , i ) => `line ${ i } ${ "x" . repeat ( 300 ) } ` ) ;
204+ await writeFile ( join ( dir , "big.txt" ) , lines . join ( "\n" ) + "\n" ) ;
205+ }
206+
207+ function truncationNoticeCount ( content : string ) : number {
208+ return ( content . match ( / \[ o u t p u t t r u n c a t e d / g) ?? [ ] ) . length ;
209+ }
210+
211+ test ( "an oversized grep result is capped and announced once through the real plugin chain" , async ( ) => {
212+ await withTempDir ( async ( dir ) => {
213+ await writeOversizedHaystack ( dir ) ;
214+ const content = await grepThroughRealChain ( dir ) ;
215+
216+ expect ( content . length ) . toBeLessThanOrEqual ( MAX_RESULT_CHARS + 200 ) ;
217+ expect ( truncationNoticeCount ( content ) ) . toBe ( 1 ) ;
218+ expect ( content ) . not . toContain ( "showing first" ) ;
219+ } ) ;
220+ } ) ;
221+
222+ test ( "an oversized grep result is capped and announced once when ripgrep is unavailable" , async ( ) => {
223+ await withTempDir ( async ( dir ) => {
224+ await writeOversizedHaystack ( dir ) ;
225+ await withoutRipgrep ( async ( ) => {
226+ const content = await grepThroughRealChain ( dir ) ;
227+
228+ expect ( content . length ) . toBeLessThanOrEqual ( MAX_RESULT_CHARS + 200 ) ;
229+ expect ( truncationNoticeCount ( content ) ) . toBe ( 1 ) ;
230+ expect ( content ) . not . toContain ( "showing first" ) ;
122231 } ) ;
123232 } ) ;
124233} ) ;
0 commit comments