@@ -152,6 +152,7 @@ describe('ShellTool', () => {
152152 getGeminiClient : vi . fn ( ) . mockReturnValue ( { } ) ,
153153 getShellToolInactivityTimeout : vi . fn ( ) . mockReturnValue ( 1000 ) ,
154154 getEnableInteractiveShell : vi . fn ( ) . mockReturnValue ( false ) ,
155+ isInteractiveShellEnabled : vi . fn ( ) . mockReturnValue ( false ) ,
155156 getShellBackgroundCompletionBehavior : vi . fn ( ) . mockReturnValue ( 'silent' ) ,
156157 getEnableShellOutputEfficiency : vi . fn ( ) . mockReturnValue ( true ) ,
157158 getSandboxEnabled : vi . fn ( ) . mockReturnValue ( false ) ,
@@ -213,7 +214,7 @@ describe('ShellTool', () => {
213214 callback : ( event : ShellOutputEvent ) => void ,
214215 ) => {
215216 mockShellOutputCallback = callback ;
216- const match = cmd . match ( / p g r e p - g 0 > ( [ ^ ] + ) / ) ;
217+ const match = cmd . match ( / _ b g p i d s _ f i l e = ( [ ^ \r \n ] + ) / ) ;
217218 if ( match ) {
218219 extractedTmpFile = match [ 1 ] . replace ( / [ ' " ] / g, '' ) ;
219220 }
@@ -308,19 +309,22 @@ describe('ShellTool', () => {
308309 resolveExecutionPromise ( fullResult ) ;
309310 } ;
310311
311- it ( 'should wrap command on linux and parse pgrep output' , async ( ) => {
312+ it ( 'should wrap command on linux and parse background PID output' , async ( ) => {
312313 const invocation = shellTool . build ( { command : 'my-command &' } ) ;
313314 const promise = invocation . execute ( { abortSignal : mockAbortSignal } ) ;
314315
315- // Simulate pgrep output file creation by the shell command
316+ // Simulate background PID output file creation by the shell command
316317 fs . writeFileSync ( extractedTmpFile , `54321${ os . EOL } 54322${ os . EOL } ` ) ;
317318
318319 resolveShellExecution ( { pid : 54321 } ) ;
319320
320321 const result = await promise ;
322+ const wrappedCommand = mockShellExecutionService . mock . calls [ 0 ] [ 0 ] ;
321323
322324 expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
323- expect . stringMatching ( / p g r e p - g 0 > .* g e m i n i - s h e l l - .* [ / \\ ] p g r e p \. t m p / ) ,
325+ expect . stringMatching (
326+ / _ b g p i d s _ f i l e = .* g e m i n i - s h e l l - .* [ / \\ ] b g p i d s \. t m p [ ' " ] ? \n \( \n { 2 } t r a p ' j o b s - p > " \$ _ b g p i d s _ f i l e " ' E X I T / ,
327+ ) ,
324328 tempRootDir ,
325329 expect . any ( Function ) ,
326330 expect . any ( AbortSignal ) ,
@@ -331,19 +335,67 @@ describe('ShellTool', () => {
331335 sandboxManager : expect . any ( Object ) ,
332336 } ) ,
333337 ) ;
338+ expect ( wrappedCommand ) . toMatch (
339+ / ^ _ b g p i d s _ f i l e = .* \n \( \n { 2 } t r a p ' j o b s - p > " \$ _ b g p i d s _ f i l e " ' E X I T \n m y - c o m m a n d & \n \) \n _ _ c o d e = \$ \? \n e x i t \$ _ _ c o d e $ / ,
340+ ) ;
334341 expect ( result . llmContent ) . toContain ( 'Background PIDs: 54322' ) ;
335342 // The file should be deleted by the tool
336343 expect ( fs . existsSync ( extractedTmpFile ) ) . toBe ( false ) ;
337344 } ) ;
338345
346+ it ( 'should preserve exit code and capture background PIDs when command uses explicit exit' , async ( ) => {
347+ const invocation = shellTool . build ( {
348+ command : "sh -c 'sleep 60 & exit 1'" ,
349+ } ) ;
350+ const promise = invocation . execute ( { abortSignal : mockAbortSignal } ) ;
351+
352+ fs . writeFileSync ( extractedTmpFile , `67890${ os . EOL } ` ) ;
353+ expect ( fs . readFileSync ( extractedTmpFile , 'utf8' ) . trim ( ) ) . toBe ( '67890' ) ;
354+
355+ resolveShellExecution ( { exitCode : 1 , output : '' } ) ;
356+
357+ const result = await promise ;
358+ const wrappedCommand = mockShellExecutionService . mock . calls [ 0 ] [ 0 ] ;
359+
360+ expect ( wrappedCommand ) . toContain (
361+ 'trap \'jobs -p > "$_bgpids_file"\' EXIT' ,
362+ ) ;
363+ expect ( wrappedCommand ) . toContain ( 'sleep 60 & exit 1' ) ;
364+ expect ( result . llmContent ) . toContain ( 'Exit Code: 1' ) ;
365+ expect ( result . llmContent ) . toContain ( 'Background PIDs: 67890' ) ;
366+ expect ( fs . existsSync ( extractedTmpFile ) ) . toBe ( false ) ;
367+ } ) ;
368+
369+ it ( 'should disable PTY execution when interactive shell is unavailable' , async ( ) => {
370+ ( mockConfig . getEnableInteractiveShell as Mock ) . mockReturnValue ( true ) ;
371+ ( mockConfig . isInteractiveShellEnabled as Mock ) . mockReturnValue ( false ) ;
372+
373+ const invocation = shellTool . build ( { command : 'python --version' } ) ;
374+ const promise = invocation . execute ( { abortSignal : mockAbortSignal } ) ;
375+ resolveShellExecution ( ) ;
376+
377+ await promise ;
378+
379+ expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
380+ expect . any ( String ) ,
381+ tempRootDir ,
382+ expect . any ( Function ) ,
383+ expect . any ( AbortSignal ) ,
384+ false ,
385+ expect . objectContaining ( {
386+ pager : 'cat' ,
387+ } ) ,
388+ ) ;
389+ } ) ;
390+
339391 it ( 'should add a space when command ends with a backslash to prevent escaping newline' , async ( ) => {
340392 const invocation = shellTool . build ( { command : 'ls\\' } ) ;
341393 const promise = invocation . execute ( { abortSignal : mockAbortSignal } ) ;
342394 resolveShellExecution ( ) ;
343395 await promise ;
344396
345397 expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
346- expect . stringMatching ( / p g r e p - g 0 > .* g e m i n i - s h e l l - .* [ / \\ ] p g r e p \. t m p / ) ,
398+ expect . stringMatching ( / _ b g p i d s _ f i l e = .* g e m i n i - s h e l l - .* [ / \\ ] b g p i d s \. t m p / ) ,
347399 tempRootDir ,
348400 expect . any ( Function ) ,
349401 expect . any ( AbortSignal ) ,
@@ -359,7 +411,7 @@ describe('ShellTool', () => {
359411 await promise ;
360412
361413 expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
362- expect . stringMatching ( / p g r e p - g 0 > .* g e m i n i - s h e l l - .* [ / \\ ] p g r e p \. t m p / ) ,
414+ expect . stringMatching ( / _ b g p i d s _ f i l e = .* g e m i n i - s h e l l - .* [ / \\ ] b g p i d s \. t m p / ) ,
363415 tempRootDir ,
364416 expect . any ( Function ) ,
365417 expect . any ( AbortSignal ) ,
@@ -379,7 +431,7 @@ describe('ShellTool', () => {
379431 await promise ;
380432
381433 expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
382- expect . stringMatching ( / p g r e p - g 0 > .* g e m i n i - s h e l l - .* [ / \\ ] p g r e p \. t m p / ) ,
434+ expect . stringMatching ( / _ b g p i d s _ f i l e = .* g e m i n i - s h e l l - .* [ / \\ ] b g p i d s \. t m p / ) ,
383435 subdir ,
384436 expect . any ( Function ) ,
385437 expect . any ( AbortSignal ) ,
@@ -402,7 +454,7 @@ describe('ShellTool', () => {
402454 await promise ;
403455
404456 expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
405- expect . stringMatching ( / p g r e p - g 0 > .* g e m i n i - s h e l l - .* [ / \\ ] p g r e p \. t m p / ) ,
457+ expect . stringMatching ( / _ b g p i d s _ f i l e = .* g e m i n i - s h e l l - .* [ / \\ ] b g p i d s \. t m p / ) ,
406458 path . join ( tempRootDir , 'subdir' ) ,
407459 expect . any ( Function ) ,
408460 expect . any ( AbortSignal ) ,
@@ -481,7 +533,7 @@ EOF`;
481533 await promise ;
482534
483535 expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
484- expect . stringMatching ( / p g r e p - g 0 > .* g e m i n i - s h e l l - .* [ / \\ ] p g r e p \. t m p / ) ,
536+ expect . stringMatching ( / _ b g p i d s _ f i l e = .* g e m i n i - s h e l l - .* [ / \\ ] b g p i d s \. t m p / ) ,
485537 tempRootDir ,
486538 expect . any ( Function ) ,
487539 expect . any ( AbortSignal ) ,
@@ -508,7 +560,7 @@ EOF`;
508560
509561 const result = await promise ;
510562 expect ( result . llmContent ) . toContain ( 'Error: wrapped command failed' ) ;
511- expect ( result . llmContent ) . not . toContain ( 'pgrep ' ) ;
563+ expect ( result . llmContent ) . not . toContain ( 'background pid output ' ) ;
512564 expect ( result . display ) . toEqual (
513565 expect . objectContaining ( {
514566 name : 'Shell' ,
@@ -599,7 +651,7 @@ EOF`;
599651 it ( 'should clean up the temp file on synchronous execution error' , async ( ) => {
600652 const error = new Error ( 'sync spawn error' ) ;
601653 mockShellExecutionService . mockImplementation ( ( cmd : string ) => {
602- const match = cmd . match ( / p g r e p - g 0 > ( [ ^ ] + ) / ) ;
654+ const match = cmd . match ( / _ b g p i d s _ f i l e = ( [ ^ \r \n ] + ) / ) ;
603655 if ( match ) {
604656 extractedTmpFile = match [ 1 ] . replace ( / [ ' " ] / g, '' ) ; // remove any quotes if present
605657 // Create the temp file before throwing to simulate it being left behind
@@ -616,7 +668,7 @@ EOF`;
616668 expect ( fs . existsSync ( extractedTmpFile ) ) . toBe ( false ) ;
617669 } ) ;
618670
619- it ( 'should not log "missing pgrep output" when process is backgrounded' , async ( ) => {
671+ it ( 'should not log "missing background pid output" when process is backgrounded' , async ( ) => {
620672 vi . useFakeTimers ( ) ;
621673 const debugErrorSpy = vi . spyOn ( debugLogger , 'error' ) ;
622674
@@ -631,7 +683,9 @@ EOF`;
631683
632684 await promise ;
633685
634- expect ( debugErrorSpy ) . not . toHaveBeenCalledWith ( 'missing pgrep output' ) ;
686+ expect ( debugErrorSpy ) . not . toHaveBeenCalledWith (
687+ 'missing background pid output' ,
688+ ) ;
635689 } ) ;
636690
637691 describe ( 'Streaming to `updateOutput`' , ( ) => {
0 commit comments