@@ -4,8 +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" ;
8- import { resultTruncationPlugin } from "../../src/plugins/result-truncation-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" ;
913import type { RgChild , SpawnRg } from "../../src/plugins/rg-run.js" ;
1014
1115// Repo root derived from this file, not process.cwd(): these cases search real
@@ -151,12 +155,11 @@ test("the output byte cap holds when ripgrep is unavailable", async () => {
151155} ) ;
152156
153157// A grep run that both breaches the byte cap (rg-output.ts) and matches more
154- // lines than max_results (ripgrep-plugin.ts's own count cap) used to carry
155- // two notices: capLines added its own "(showing first N of M+ lines...)"
156- // text on top of whatever the byte-cap breach had already reported, because
157- // partialContent concatenated both unconditionally. It must report the
158- // truncation exactly once.
159- test ( "a grep result that hits both the byte cap and the match-count cap carries exactly one truncation notice" , async ( ) => {
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 ( ) => {
160163 // 400 matched lines emitted directly, bypassing a real `rg` process so
161164 // nothing upstream of ripgrep-plugin.ts pre-limits the line count.
162165 const result = await run (
@@ -167,37 +170,65 @@ test("a grep result that hits both the byte cap and the match-count cap carries
167170
168171 expect ( result . isError ) . toBeUndefined ( ) ;
169172 const content = String ( result . content ) ;
170- // Both grep-specific caps fired (byte cap at 200 bytes, line cap at 3
171- // matches) but neither attaches its own notice — ripgrep-plugin.ts leaves
172- // that to result-truncation-plugin.ts, which runs later in the real chain
173- // and sees the final content. A regression that reintroduces either cap's
174- // own notice text would fail this.
175- expect ( content . split ( "\n" ) . length ) . toBeLessThanOrEqual ( 3 ) ;
176- expect ( content ) . not . toMatch ( / s h o w i n g f i r s t | e x c e e d e d \d + b y t e s | t i m e d o u t / ) ;
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 / ) ;
177176} ) ;
178177
179- // The same result, run through the full chain (ripgrepPlugin then
180- // result-truncation-plugin, matching buildCorePosixToolPlugins in
181- // src/agent/posix-tool-plugins.ts), still carries at most one notice — the
182- // grep-specific caps stay silent and result-truncation-plugin.ts's char cap
183- // is the backstop for content that's still oversized after them.
184- test ( "a large grep result carries at most one truncation notice through the plugin chain" , async ( ) => {
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 ( ) => {
185212 await withTempDir ( async ( dir ) => {
186- const lines = Array . from ( { length : 1000 } , ( _ , i ) => `line ${ i } ${ "x" . repeat ( 300 ) } ` ) ;
187- await writeFile ( join ( dir , "big.txt" ) , lines . join ( "\n" ) + "\n" ) ;
188-
189- const grepHandler = ripgrepPlugin ( dir ) . middleware ! ( fallback ) ;
190- const handler = resultTruncationPlugin ( ) . middleware ! ( grepHandler ) ;
191- const result = await handler (
192- { id : "c" , name : "grep" , arguments : { pattern : "line" , path : dir } } ,
193- new AbortController ( ) . signal ,
194- ) ;
213+ await writeOversizedHaystack ( dir ) ;
214+ const content = await grepThroughRealChain ( dir ) ;
195215
196- expect ( result . isError ) . toBeUndefined ( ) ;
197- const content = String ( result . content ) ;
198- expect ( content ) . toContain ( "output truncated" ) ;
216+ expect ( content . length ) . toBeLessThanOrEqual ( MAX_RESULT_CHARS + 200 ) ;
217+ expect ( truncationNoticeCount ( content ) ) . toBe ( 1 ) ;
199218 expect ( content ) . not . toContain ( "showing first" ) ;
200- expect ( ( content . match ( / \[ o u t p u t t r u n c a t e d / g) ?? [ ] ) . length ) . toBe ( 1 ) ;
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" ) ;
231+ } ) ;
201232 } ) ;
202233} ) ;
203234
0 commit comments