@@ -31,13 +31,15 @@ import {
3131 type ReconnectParams ,
3232 type IStateSnapshot ,
3333} from '../common/state/sessionProtocol.js' ;
34- import { ROOT_STATE_URI , SessionStatus } from '../common/state/sessionState.js' ;
34+ import { ResponsePartKind , ROOT_STATE_URI , SessionStatus , ToolCallConfirmationReason , ToolCallStatus , ToolResultContentType , type SessionState } from '../common/state/sessionState.js' ;
3535import type { IProtocolServer , IProtocolTransport } from '../common/state/sessionTransport.js' ;
3636import { AgentHostStateManager } from './agentHostStateManager.js' ;
3737
3838/** Default capacity of the server-side action replay buffer. */
3939const REPLAY_BUFFER_CAPACITY = 1000 ;
4040
41+ const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000 ;
42+
4143/** Build a JSON-RPC success response suitable for transport.send(). */
4244function jsonRpcSuccess ( id : number , result : unknown ) : JsonRpcResponse {
4345 return { jsonrpc : '2.0' , id, result } ;
@@ -100,6 +102,7 @@ export class ProtocolServerHandler extends Disposable {
100102
101103 private readonly _clients = new Map < string , IConnectedClient > ( ) ;
102104 private readonly _replayBuffer : ActionEnvelope [ ] = [ ] ;
105+ private readonly _clientToolCallDisconnectTimeouts = new Map < string , ReturnType < typeof setTimeout > > ( ) ;
103106
104107 private readonly _onDidChangeConnectionCount = this . _register ( new Emitter < number > ( ) ) ;
105108
@@ -206,6 +209,7 @@ export class ProtocolServerHandler extends Disposable {
206209 this . _logService . info ( `[ProtocolServer] Client disconnected: ${ client . clientId } , subscriptions=${ client . subscriptions . size } ` ) ;
207210 this . _clients . delete ( client . clientId ) ;
208211 this . _rejectPendingReverseRequests ( client . clientId ) ;
212+ this . _handleClientDisconnected ( client . clientId ) ;
209213 this . _onDidChangeConnectionCount . fire ( this . _clients . size ) ;
210214 }
211215 disposables . dispose ( ) ;
@@ -256,6 +260,7 @@ export class ProtocolServerHandler extends Disposable {
256260 if ( snapshot ) {
257261 snapshots . push ( snapshot ) ;
258262 client . subscriptions . add ( uri . toString ( ) ) ;
263+ this . _clearClientToolCallDisconnectTimeout ( params . clientId , uri . toString ( ) ) ;
259264 }
260265 }
261266 }
@@ -295,6 +300,7 @@ export class ProtocolServerHandler extends Disposable {
295300 const actions : ActionEnvelope [ ] = [ ] ;
296301 for ( const sub of params . subscriptions ) {
297302 client . subscriptions . add ( sub . toString ( ) ) ;
303+ this . _clearClientToolCallDisconnectTimeout ( params . clientId , sub . toString ( ) ) ;
298304 }
299305 for ( const envelope of this . _replayBuffer ) {
300306 if ( envelope . serverSeq > params . lastSeenServerSeq ) {
@@ -311,12 +317,108 @@ export class ProtocolServerHandler extends Disposable {
311317 if ( snapshot ) {
312318 snapshots . push ( snapshot ) ;
313319 client . subscriptions . add ( sub ) ;
320+ this . _clearClientToolCallDisconnectTimeout ( params . clientId , sub ) ;
314321 }
315322 }
316323 return { client, response : { type : 'snapshot' , snapshots } } ;
317324 }
318325 }
319326
327+ private _handleClientDisconnected ( clientId : string ) : void {
328+ for ( const session of this . _stateManager . getSessionUris ( ) ) {
329+ const state = this . _stateManager . getSessionState ( session ) ;
330+ const ownsPendingToolCall = state ? this . _hasPendingClientToolCall ( state , clientId ) : false ;
331+ if ( state ?. activeClient ?. clientId === clientId ) {
332+ this . _stateManager . dispatchServerAction ( {
333+ type : ActionType . SessionActiveClientChanged ,
334+ session,
335+ activeClient : null ,
336+ } ) ;
337+ }
338+ if ( state ?. activeClient ?. clientId === clientId || ownsPendingToolCall ) {
339+ this . _startClientToolCallDisconnectTimeout ( clientId , session ) ;
340+ }
341+ }
342+ }
343+
344+ private _hasPendingClientToolCall ( state : ReturnType < AgentHostStateManager [ 'getSessionState' ] > , clientId : string ) : boolean {
345+ const activeTurn = state ?. activeTurn ;
346+ if ( ! activeTurn ) {
347+ return false ;
348+ }
349+ return activeTurn . responseParts . some ( part => part . kind === ResponsePartKind . ToolCall
350+ && part . toolCall . toolClientId === clientId
351+ && ( part . toolCall . status === ToolCallStatus . Streaming || part . toolCall . status === ToolCallStatus . Running || part . toolCall . status === ToolCallStatus . PendingConfirmation ) ) ;
352+ }
353+
354+ private _hasReplacementActiveClientTool ( state : SessionState , clientId : string , toolName : string ) : boolean {
355+ const activeClient = state . activeClient ;
356+ return activeClient !== undefined
357+ && activeClient . clientId !== clientId
358+ && activeClient . tools . some ( tool => tool . name === toolName ) ;
359+ }
360+
361+ private _startClientToolCallDisconnectTimeout ( clientId : string , session : string ) : void {
362+ this . _clearClientToolCallDisconnectTimeout ( clientId , session ) ;
363+ const key = this . _clientToolCallDisconnectTimeoutKey ( clientId , session ) ;
364+ this . _clientToolCallDisconnectTimeouts . set ( key , setTimeout ( ( ) => {
365+ this . _clientToolCallDisconnectTimeouts . delete ( key ) ;
366+ this . _completeDisconnectedClientToolCalls ( clientId , session ) ;
367+ } , CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT ) ) ;
368+ }
369+
370+ private _clearClientToolCallDisconnectTimeout ( clientId : string , session : string ) : void {
371+ const key = this . _clientToolCallDisconnectTimeoutKey ( clientId , session ) ;
372+ const timeout = this . _clientToolCallDisconnectTimeouts . get ( key ) ;
373+ if ( timeout ) {
374+ clearTimeout ( timeout ) ;
375+ this . _clientToolCallDisconnectTimeouts . delete ( key ) ;
376+ }
377+ }
378+
379+ private _clientToolCallDisconnectTimeoutKey ( clientId : string , session : string ) : string {
380+ return `${ clientId } \n${ session } ` ;
381+ }
382+
383+ private _completeDisconnectedClientToolCalls ( clientId : string , session : string ) : void {
384+ const state = this . _stateManager . getSessionState ( session ) ;
385+ const activeTurn = state ?. activeTurn ;
386+ if ( ! activeTurn ) {
387+ return ;
388+ }
389+ for ( const part of activeTurn . responseParts ) {
390+ if ( part . kind !== ResponsePartKind . ToolCall ) {
391+ continue ;
392+ }
393+ const toolCall = part . toolCall ;
394+ if ( toolCall . toolClientId === clientId && ( toolCall . status === ToolCallStatus . Streaming || toolCall . status === ToolCallStatus . Running || toolCall . status === ToolCallStatus . PendingConfirmation ) ) {
395+ const mayRetryWithReplacementClient = this . _hasReplacementActiveClientTool ( state , clientId , toolCall . toolName ) ;
396+ if ( toolCall . status === ToolCallStatus . Streaming ) {
397+ this . _stateManager . dispatchServerAction ( {
398+ type : ActionType . SessionToolCallReady ,
399+ session,
400+ turnId : activeTurn . id ,
401+ toolCallId : toolCall . toolCallId ,
402+ invocationMessage : toolCall . invocationMessage ?? toolCall . displayName ,
403+ confirmed : ToolCallConfirmationReason . NotNeeded ,
404+ } ) ;
405+ }
406+ this . _stateManager . dispatchServerAction ( {
407+ type : ActionType . SessionToolCallComplete ,
408+ session,
409+ turnId : activeTurn . id ,
410+ toolCallId : toolCall . toolCallId ,
411+ result : {
412+ success : false ,
413+ pastTenseMessage : `${ toolCall . displayName } failed` ,
414+ ...( mayRetryWithReplacementClient ? { content : [ { type : ToolResultContentType . Text , text : `The client that was running ${ toolCall . displayName } disconnected, but another active client now provides ${ toolCall . displayName } . You may try calling the tool again.` } ] } : { } ) ,
415+ error : { message : `Client ${ clientId } disconnected before completing ${ toolCall . displayName } ` } ,
416+ } ,
417+ } ) ;
418+ }
419+ }
420+ }
421+
320422 // ---- Requests (expect a response) ---------------------------------------
321423
322424 /**
@@ -328,6 +430,7 @@ export class ProtocolServerHandler extends Disposable {
328430 try {
329431 const snapshot = await this . _agentService . subscribe ( URI . parse ( params . resource ) ) ;
330432 client . subscriptions . add ( params . resource ) ;
433+ this . _clearClientToolCallDisconnectTimeout ( client . clientId , params . resource ) ;
331434 return { snapshot } ;
332435 } catch ( err ) {
333436 if ( err instanceof ProtocolError ) {
@@ -606,6 +709,10 @@ export class ProtocolServerHandler extends Disposable {
606709 pending . reject ( new Error ( 'ProtocolServerHandler disposed' ) ) ;
607710 }
608711 this . _pendingReverseRequests . clear ( ) ;
712+ for ( const timeout of this . _clientToolCallDisconnectTimeouts . values ( ) ) {
713+ clearTimeout ( timeout ) ;
714+ }
715+ this . _clientToolCallDisconnectTimeouts . clear ( ) ;
609716 this . _replayBuffer . length = 0 ;
610717 super . dispose ( ) ;
611718 }
0 commit comments