@@ -1349,6 +1349,90 @@ describe("runCliAgent spawn path", () => {
13491349 }
13501350 } ) ;
13511351
1352+ it ( "extends the live no-output watchdog to the blocked-tool floor while a tool is outstanding" , async ( ) => {
1353+ const toolErrorEvents : Array < Record < string , unknown > > = [ ] ;
1354+ const stopDiagnostics = onTrustedToolExecutionEvent ( ( event ) => {
1355+ if ( event . type === "tool.execution.error" ) {
1356+ toolErrorEvents . push ( event as unknown as Record < string , unknown > ) ;
1357+ }
1358+ } ) ;
1359+ let stdoutListener : ( ( chunk : string ) => void ) | undefined ;
1360+ const cancel = vi . fn ( ) ;
1361+ const stdin = {
1362+ write : vi . fn ( ( _data : string , callback ?: ( error ?: Error | null ) => void ) => {
1363+ stdoutListener ?.(
1364+ [
1365+ JSON . stringify ( { type : "system" , subtype : "init" , session_id : "live-quiet-tool" } ) ,
1366+ JSON . stringify ( {
1367+ type : "assistant" ,
1368+ message : {
1369+ content : [ { type : "tool_use" , id : "tool-quiet-1" , name : "Bash" , input : { } } ] ,
1370+ } ,
1371+ } ) ,
1372+ ] . join ( "\n" ) + "\n" ,
1373+ ) ;
1374+ callback ?.( ) ;
1375+ } ) ,
1376+ end : vi . fn ( ) ,
1377+ } ;
1378+ supervisorSpawnMock . mockImplementationOnce ( async ( ...args : unknown [ ] ) => {
1379+ const input = ( args [ 0 ] ?? { } ) as { onStdout ?: ( chunk : string ) => void } ;
1380+ stdoutListener = input . onStdout ;
1381+ return {
1382+ runId : "live-quiet-tool-run" ,
1383+ pid : 2345 ,
1384+ startedAtMs : Date . now ( ) ,
1385+ stdin,
1386+ wait : vi . fn ( ( ) => new Promise ( ( ) => { } ) ) ,
1387+ cancel,
1388+ } ;
1389+ } ) ;
1390+
1391+ const run = executePreparedCliRun (
1392+ buildPreparedCliRunContext ( {
1393+ provider : "claude-cli" ,
1394+ model : "sonnet" ,
1395+ runId : "run-live-quiet-tool" ,
1396+ backend : { liveSession : "claude-stdio" } ,
1397+ timeoutMs : 3_600_000 ,
1398+ } ) ,
1399+ ) ;
1400+ const rejection = expect ( run ) . rejects . toThrow ( / p r o d u c e d n o o u t p u t f o r 9 0 0 s / ) ;
1401+ await vi . waitFor ( ( ) => {
1402+ expect ( stdin . write ) . toHaveBeenCalledOnce ( ) ;
1403+ } ) ;
1404+
1405+ // Fake the clock only after the spawn path settled, then emit one more
1406+ // stdout line so the watchdog re-arms on the faked setTimeout/Date.
1407+ vi . useFakeTimers ( { toFake : [ "setTimeout" , "clearTimeout" , "Date" ] } ) ;
1408+ stdoutListener ?.(
1409+ `${ JSON . stringify ( {
1410+ type : "stream_event" ,
1411+ event : { type : "content_block_delta" , delta : { type : "text_delta" , text : "running" } } ,
1412+ } ) } \n`,
1413+ ) ;
1414+
1415+ // Base watchdog (600s cap for a 1h budget) must not kill the quiet tool.
1416+ await vi . advanceTimersByTimeAsync ( 650_000 ) ;
1417+ expect ( cancel ) . not . toHaveBeenCalled ( ) ;
1418+
1419+ // The blocked-tool floor (15min of quiet) still terminates a wedged tool.
1420+ try {
1421+ await vi . advanceTimersByTimeAsync ( 300_000 ) ;
1422+ expect ( cancel ) . toHaveBeenCalledWith ( "manual-cancel" ) ;
1423+ await rejection ;
1424+ // Watchdog-killed turns must keep timeout provenance for active tools.
1425+ expect ( toolErrorEvents ) . toContainEqual (
1426+ expect . objectContaining ( {
1427+ toolCallId : "tool-quiet-1" ,
1428+ terminalReason : "timed_out" ,
1429+ } ) ,
1430+ ) ;
1431+ } finally {
1432+ stopDiagnostics ( ) ;
1433+ }
1434+ } ) ;
1435+
13521436 it ( "keeps non-capture live prepared backend cleanup with the whole-run owner" , async ( ) => {
13531437 let stdoutListener : ( ( chunk : string ) => void ) | undefined ;
13541438 const stdin = {
@@ -2692,97 +2776,6 @@ ${JSON.stringify({
26922776 } ,
26932777 ) ;
26942778
2695- it ( "preserves no-output watchdog timeout provenance for active Claude live tools" , async ( ) => {
2696- const diagnosticEvents : Array < Record < string , unknown > > = [ ] ;
2697- const stopDiagnostics = onTrustedToolExecutionEvent ( ( event ) => {
2698- if ( event . type === "tool.execution.error" ) {
2699- diagnosticEvents . push ( event as unknown as Record < string , unknown > ) ;
2700- }
2701- } ) ;
2702- let stdoutListener : ( ( chunk : string ) => void ) | undefined ;
2703- const stdin = {
2704- write : vi . fn ( ( _data : string , cb ?: ( err ?: Error | null ) => void ) => {
2705- stdoutListener ?.(
2706- [
2707- JSON . stringify ( { type : "system" , subtype : "init" , session_id : "live-no-output" } ) ,
2708- JSON . stringify ( {
2709- type : "assistant" ,
2710- session_id : "live-no-output" ,
2711- message : {
2712- role : "assistant" ,
2713- content : [
2714- {
2715- type : "tool_use" ,
2716- id : "tool-live-no-output" ,
2717- name : "Bash" ,
2718- input : { command : "sleep 10" } ,
2719- } ,
2720- ] ,
2721- } ,
2722- } ) ,
2723- ] . join ( "\n" ) + "\n" ,
2724- ) ;
2725- cb ?.( ) ;
2726- } ) ,
2727- end : vi . fn ( ) ,
2728- } ;
2729- supervisorSpawnMock . mockImplementation ( async ( ...args : unknown [ ] ) => {
2730- const input = ( args [ 0 ] ?? { } ) as { onStdout ?: ( chunk : string ) => void } ;
2731- stdoutListener = input . onStdout ;
2732- return {
2733- runId : "live-run-no-output" ,
2734- pid : 3062 ,
2735- startedAtMs : Date . now ( ) ,
2736- stdin,
2737- wait : vi . fn ( ( ) => new Promise ( ( ) => { } ) ) ,
2738- cancel : vi . fn ( ) ,
2739- } ;
2740- } ) ;
2741-
2742- try {
2743- const context = buildPreparedCliRunContext ( {
2744- provider : "claude-cli" ,
2745- model : "sonnet" ,
2746- runId : "run-live-no-output" ,
2747- sessionId : "session-live-no-output" ,
2748- sessionKey : "agent:main:no-output" ,
2749- backend : { liveSession : "claude-stdio" } ,
2750- timeoutMs : 120_000 ,
2751- } ) ;
2752- const resultPromise = runClaudeLiveSessionTurn ( {
2753- context,
2754- args : context . preparedBackend . backend . args ?? [ ] ,
2755- env : { } ,
2756- prompt : "hello" ,
2757- useResume : false ,
2758- noOutputTimeoutMs : 25 ,
2759- getProcessSupervisor : ( ) => ( {
2760- spawn : ( params : Parameters < SupervisorSpawnFn > [ 0 ] ) =>
2761- supervisorSpawnMock ( params ) as ReturnType < SupervisorSpawnFn > ,
2762- cancel : vi . fn ( ) ,
2763- cancelScope : vi . fn ( ) ,
2764- getRecord : vi . fn ( ) ,
2765- } ) ,
2766- onAssistantDelta : ( ) => { } ,
2767- cleanup : async ( ) => { } ,
2768- } ) ;
2769- const runExpectation = expectRejectsWithFields ( resultPromise , {
2770- name : "FailoverError" ,
2771- message : "CLI produced no output for 0s and was terminated." ,
2772- } ) ;
2773-
2774- await runExpectation ;
2775- expect ( diagnosticEvents ) . toContainEqual (
2776- expect . objectContaining ( {
2777- toolCallId : "tool-live-no-output" ,
2778- terminalReason : "timed_out" ,
2779- } ) ,
2780- ) ;
2781- } finally {
2782- stopDiagnostics ( ) ;
2783- }
2784- } ) ;
2785-
27862779 it ( "answers Claude live control_request can_use_tool with deny when exec policy is restrictive" , async ( ) => {
27872780 const diagnosticEvents : Array < Record < string , unknown > > = [ ] ;
27882781 const stopDiagnostics = onInternalDiagnosticEvent ( ( event ) => {
0 commit comments