44 * SPDX-License-Identifier: Apache-2.0
55 */
66
7- import { describe , expect , it , vi } from 'vitest' ;
7+ import { describe , expect , it , vi , beforeEach } from 'vitest' ;
88import { RequestError } from '@agentclientprotocol/sdk' ;
99import type { ContentBlock } from '@agentclientprotocol/sdk' ;
1010
@@ -21,8 +21,11 @@ type AcpConnectionInternal = {
2121 sessionId : string | null ;
2222 lastExitCode : number | null ;
2323 lastExitSignal : string | null ;
24+ intentionalDisconnect : boolean ;
25+ autoReconnectAttempts : number ;
2426 mapReadTextFileError : ( error : unknown , filePath : string ) => unknown ;
2527 ensureConnection : ( ) => unknown ;
28+ cleanupForRetry : ( ) => void ;
2629} ;
2730
2831function createConnection ( overrides ?: Partial < AcpConnectionInternal > ) {
@@ -224,3 +227,174 @@ describe('AcpConnection lastExitCode/lastExitSignal', () => {
224227 expect ( conn . lastExitSignal ) . toBeNull ( ) ;
225228 } ) ;
226229} ) ;
230+
231+ describe ( 'AcpConnection.connectWithRetry' , ( ) => {
232+ let acpConn : AcpConnection ;
233+
234+ beforeEach ( ( ) => {
235+ acpConn = new AcpConnection ( ) ;
236+ } ) ;
237+
238+ it ( 'succeeds on first attempt without retrying' , async ( ) => {
239+ const connectSpy = vi
240+ . spyOn ( acpConn , 'connect' )
241+ . mockResolvedValueOnce ( undefined ) ;
242+
243+ await acpConn . connectWithRetry ( '/path/to/cli.js' , '/workdir' , [ ] , 3 ) ;
244+
245+ expect ( connectSpy ) . toHaveBeenCalledTimes ( 1 ) ;
246+ expect ( connectSpy ) . toHaveBeenCalledWith ( '/path/to/cli.js' , '/workdir' , [ ] ) ;
247+ } ) ;
248+
249+ it ( 'retries on failure and succeeds on second attempt' , async ( ) => {
250+ const connectSpy = vi
251+ . spyOn ( acpConn , 'connect' )
252+ . mockRejectedValueOnce ( new Error ( 'SIGTERM' ) )
253+ . mockResolvedValueOnce ( undefined ) ;
254+
255+ await acpConn . connectWithRetry ( '/path/to/cli.js' , '/workdir' , [ ] , 3 ) ;
256+
257+ expect ( connectSpy ) . toHaveBeenCalledTimes ( 2 ) ;
258+ } ) ;
259+
260+ it ( 'throws after all retries are exhausted' , async ( ) => {
261+ const error = new Error ( 'persistent failure' ) ;
262+ const connectSpy = vi . spyOn ( acpConn , 'connect' ) . mockRejectedValue ( error ) ;
263+
264+ await expect (
265+ acpConn . connectWithRetry ( '/path/to/cli.js' , '/workdir' , [ ] , 2 ) ,
266+ ) . rejects . toThrow ( 'persistent failure' ) ;
267+
268+ // 1 initial + 2 retries = 3 total
269+ expect ( connectSpy ) . toHaveBeenCalledTimes ( 3 ) ;
270+ } ) ;
271+
272+ it ( 'cleans up state between retry attempts' , async ( ) => {
273+ const internal = acpConn as unknown as AcpConnectionInternal ;
274+ const cleanupSpy = vi . spyOn ( internal , 'cleanupForRetry' as never ) ;
275+
276+ vi . spyOn ( acpConn , 'connect' )
277+ . mockRejectedValueOnce ( new Error ( 'fail 1' ) )
278+ . mockResolvedValueOnce ( undefined ) ;
279+
280+ await acpConn . connectWithRetry ( '/path/to/cli.js' , '/workdir' , [ ] , 3 ) ;
281+
282+ // cleanupForRetry called once for the failed attempt
283+ expect ( cleanupSpy ) . toHaveBeenCalledTimes ( 1 ) ;
284+ } ) ;
285+
286+ it ( 'resets autoReconnectAttempts on successful connect' , async ( ) => {
287+ const internal = acpConn as unknown as AcpConnectionInternal ;
288+ internal . autoReconnectAttempts = 5 ;
289+
290+ vi . spyOn ( acpConn , 'connect' ) . mockResolvedValueOnce ( undefined ) ;
291+
292+ await acpConn . connectWithRetry ( '/path/to/cli.js' , '/workdir' , [ ] , 3 ) ;
293+
294+ expect ( acpConn . currentAutoReconnectAttempts ) . toBe ( 0 ) ;
295+ } ) ;
296+ } ) ;
297+
298+ describe ( 'AcpConnection.cleanupForRetry' , ( ) => {
299+ it ( 'kills zombie child process and resets state' , ( ) => {
300+ const mockKill = vi . fn ( ) ;
301+ const conn = createConnection ( {
302+ child : createMockChild ( { kill : mockKill , killed : false } ) ,
303+ sdkConnection : { fake : true } ,
304+ sessionId : 'test-session' ,
305+ lastExitCode : 1 ,
306+ lastExitSignal : 'SIGTERM' ,
307+ } ) ;
308+
309+ conn . cleanupForRetry ( ) ;
310+
311+ expect ( mockKill ) . toHaveBeenCalledOnce ( ) ;
312+ expect ( conn . child ) . toBeNull ( ) ;
313+ expect ( conn . sdkConnection ) . toBeNull ( ) ;
314+ expect ( conn . sessionId ) . toBeNull ( ) ;
315+ expect ( conn . lastExitCode ) . toBeNull ( ) ;
316+ expect ( conn . lastExitSignal ) . toBeNull ( ) ;
317+ } ) ;
318+
319+ it ( 'handles already-killed child process gracefully' , ( ) => {
320+ const conn = createConnection ( {
321+ child : createMockChild ( { killed : true } ) ,
322+ sdkConnection : { fake : true } ,
323+ sessionId : 'test' ,
324+ } ) ;
325+
326+ expect ( ( ) => conn . cleanupForRetry ( ) ) . not . toThrow ( ) ;
327+ expect ( conn . child ) . toBeNull ( ) ;
328+ } ) ;
329+
330+ it ( 'handles null child process gracefully' , ( ) => {
331+ const conn = createConnection ( {
332+ child : null ,
333+ sdkConnection : { fake : true } ,
334+ sessionId : 'test' ,
335+ } ) ;
336+
337+ expect ( ( ) => conn . cleanupForRetry ( ) ) . not . toThrow ( ) ;
338+ } ) ;
339+ } ) ;
340+
341+ describe ( 'AcpConnection intentionalDisconnect flag' , ( ) => {
342+ it ( 'defaults to false' , ( ) => {
343+ const acpConn = new AcpConnection ( ) ;
344+ expect ( acpConn . wasIntentionalDisconnect ) . toBe ( false ) ;
345+ } ) ;
346+
347+ it ( 'is set to true by disconnect()' , ( ) => {
348+ const conn = createConnection ( {
349+ child : createMockChild ( ) ,
350+ sdkConnection : { } ,
351+ sessionId : 'test' ,
352+ } ) ;
353+ const acpConn = conn as unknown as AcpConnection ;
354+
355+ acpConn . disconnect ( ) ;
356+
357+ expect ( acpConn . wasIntentionalDisconnect ) . toBe ( true ) ;
358+ } ) ;
359+
360+ it ( 'is reset to false when connect() is called' , async ( ) => {
361+ const internal = new AcpConnection ( ) as unknown as AcpConnectionInternal ;
362+ internal . intentionalDisconnect = true ;
363+
364+ // connect() will throw because we haven't set up a real subprocess,
365+ // but the flag should be reset before the error
366+ try {
367+ await ( internal as unknown as AcpConnection ) . connect (
368+ '/nonexistent/cli.js' ,
369+ '/workdir' ,
370+ ) ;
371+ } catch {
372+ // Expected to fail
373+ }
374+
375+ expect ( internal . intentionalDisconnect ) . toBe ( false ) ;
376+ } ) ;
377+ } ) ;
378+
379+ describe ( 'AcpConnection auto-reconnect counter' , ( ) => {
380+ it ( 'defaults to 0' , ( ) => {
381+ const acpConn = new AcpConnection ( ) ;
382+ expect ( acpConn . currentAutoReconnectAttempts ) . toBe ( 0 ) ;
383+ } ) ;
384+
385+ it ( 'increments via incrementAutoReconnectAttempts()' , ( ) => {
386+ const acpConn = new AcpConnection ( ) ;
387+ acpConn . incrementAutoReconnectAttempts ( ) ;
388+ expect ( acpConn . currentAutoReconnectAttempts ) . toBe ( 1 ) ;
389+ acpConn . incrementAutoReconnectAttempts ( ) ;
390+ expect ( acpConn . currentAutoReconnectAttempts ) . toBe ( 2 ) ;
391+ } ) ;
392+
393+ it ( 'resets via resetAutoReconnectAttempts()' , ( ) => {
394+ const acpConn = new AcpConnection ( ) ;
395+ acpConn . incrementAutoReconnectAttempts ( ) ;
396+ acpConn . incrementAutoReconnectAttempts ( ) ;
397+ acpConn . resetAutoReconnectAttempts ( ) ;
398+ expect ( acpConn . currentAutoReconnectAttempts ) . toBe ( 0 ) ;
399+ } ) ;
400+ } ) ;
0 commit comments