@@ -71,6 +71,61 @@ interface ToolAfterPayload {
7171 }
7272}
7373
74+ interface ToolBeforePayload {
75+ directory ?: string
76+ input ?: {
77+ tool ?: string
78+ sessionID ?: string
79+ sessionId ?: string
80+ }
81+ }
82+
83+ interface MessageUpdatedPayload {
84+ directory ?: string
85+ properties ?: {
86+ info ?: {
87+ role ?: string
88+ sessionID ?: string
89+ sessionId ?: string
90+ error ?: unknown
91+ time ?: { completed ?: number }
92+ }
93+ }
94+ }
95+
96+ interface PendingQuestionState {
97+ tool : string
98+ startedAt : number
99+ lastUpdatedAt : number
100+ }
101+
102+ const STALE_QUESTION_PREVENTION_MS = 60_000
103+
104+ function nowMs ( ) : number {
105+ return Date . now ( )
106+ }
107+
108+ function resolveToolSessionId ( payload : {
109+ input ?: { sessionID ?: string ; sessionId ?: string }
110+ } ) : string {
111+ return String ( payload . input ?. sessionID ?? payload . input ?. sessionId ?? "" ) . trim ( )
112+ }
113+
114+ function resolveMessageSessionId ( payload : MessageUpdatedPayload ) : string {
115+ return String (
116+ payload . properties ?. info ?. sessionID ?? payload . properties ?. info ?. sessionId ?? "" ,
117+ ) . trim ( )
118+ }
119+
120+ function normalizeToolName ( raw : unknown ) : string {
121+ return String ( raw ?? "" ) . trim ( ) . toLowerCase ( )
122+ }
123+
124+ function isQuestionTool ( raw : unknown ) : boolean {
125+ const tool = normalizeToolName ( raw )
126+ return tool === "question" || tool === "askuserquestion"
127+ }
128+
74129// Returns true when event error resembles recoverable transient session failure.
75130function isRecoverableError ( error : unknown ) : boolean {
76131 const candidate =
@@ -287,6 +342,7 @@ export function createSessionRecoveryHook(options: {
287342 autoResume : boolean
288343} ) : GatewayHook {
289344 const recoveringSessions = new Set < string > ( )
345+ const pendingQuestions = new Map < string , PendingQuestionState > ( )
290346 return {
291347 id : "session-recovery" ,
292348 priority : 280 ,
@@ -299,9 +355,63 @@ export function createSessionRecoveryHook(options: {
299355 const sessionId = resolveSessionId ( eventPayload )
300356 if ( sessionId ) {
301357 recoveringSessions . delete ( sessionId )
358+ pendingQuestions . delete ( sessionId )
302359 }
303360 return
304361 }
362+ if ( type === "message.updated" ) {
363+ const messagePayload = ( payload ?? { } ) as MessageUpdatedPayload
364+ const sessionId = resolveMessageSessionId ( messagePayload )
365+ if ( ! sessionId ) {
366+ return
367+ }
368+ const info = messagePayload . properties ?. info
369+ const role = String ( info ?. role ?? "" ) . trim ( ) . toLowerCase ( )
370+ if ( role === "user" ) {
371+ pendingQuestions . delete ( sessionId )
372+ return
373+ }
374+ if ( role !== "assistant" ) {
375+ return
376+ }
377+ const completed = Number . isFinite ( Number ( info ?. time ?. completed ?? Number . NaN ) )
378+ const errored = info ?. error !== undefined && info ?. error !== null
379+ if ( completed || errored ) {
380+ pendingQuestions . delete ( sessionId )
381+ return
382+ }
383+ const existing = pendingQuestions . get ( sessionId )
384+ if ( ! existing ) {
385+ return
386+ }
387+ pendingQuestions . set ( sessionId , {
388+ ...existing ,
389+ lastUpdatedAt : nowMs ( ) ,
390+ } )
391+ return
392+ }
393+ if ( type === "tool.execute.before" ) {
394+ const toolPayload = ( payload ?? { } ) as ToolBeforePayload
395+ const sessionId = resolveToolSessionId ( toolPayload )
396+ if ( ! sessionId || ! isQuestionTool ( toolPayload . input ?. tool ) ) {
397+ return
398+ }
399+ pendingQuestions . set ( sessionId , {
400+ tool : normalizeToolName ( toolPayload . input ?. tool ) ,
401+ startedAt : nowMs ( ) ,
402+ lastUpdatedAt : nowMs ( ) ,
403+ } )
404+ return
405+ }
406+ if ( type === "tool.execute.before.error" ) {
407+ const toolPayload = ( payload ?? { } ) as ToolBeforePayload
408+ const sessionId = resolveToolSessionId ( toolPayload )
409+ if ( ! sessionId || ! isQuestionTool ( toolPayload . input ?. tool ) ) {
410+ return
411+ }
412+ pendingQuestions . delete ( sessionId )
413+ return
414+ }
305415 if ( type === "session.idle" ) {
306416 const directory =
307417 typeof eventPayload . directory === "string" && eventPayload . directory . trim ( )
@@ -315,6 +425,19 @@ export function createSessionRecoveryHook(options: {
315425 if ( ! client || typeof client . messages !== "function" ) {
316426 return
317427 }
428+ const pendingQuestion = pendingQuestions . get ( sessionId )
429+ if ( pendingQuestion ) {
430+ const ageMs = Math . max ( 0 , nowMs ( ) - Math . max ( pendingQuestion . startedAt , pendingQuestion . lastUpdatedAt ) )
431+ if ( ageMs < STALE_QUESTION_PREVENTION_MS ) {
432+ writeGatewayEventAudit ( directory , {
433+ hook : "session-recovery" ,
434+ stage : "skip" ,
435+ reason_code : "stale_question_tool_prevention_not_stale" ,
436+ session_id : sessionId ,
437+ } )
438+ return
439+ }
440+ }
318441 try {
319442 const response = await client . messages ( {
320443 path : { id : sessionId } ,
@@ -359,6 +482,7 @@ export function createSessionRecoveryHook(options: {
359482 } )
360483 } finally {
361484 recoveringSessions . delete ( sessionId )
485+ pendingQuestions . delete ( sessionId )
362486 }
363487 } catch {
364488 writeGatewayEventAudit ( directory , {
@@ -373,6 +497,10 @@ export function createSessionRecoveryHook(options: {
373497 if ( type === "tool.execute.after" ) {
374498 const toolPayload = ( payload ?? { } ) as ToolAfterPayload
375499 const sessionId = String ( toolPayload . input ?. sessionID ?? toolPayload . input ?. sessionId ?? "" ) . trim ( )
500+ if ( sessionId && isQuestionTool ( toolPayload . input ?. tool ) ) {
501+ pendingQuestions . delete ( sessionId )
502+ return
503+ }
376504 const directory =
377505 typeof toolPayload . directory === "string" && toolPayload . directory . trim ( )
378506 ? toolPayload . directory
0 commit comments