@@ -6,6 +6,7 @@ import type {
66 EventSessionIdle ,
77 OpencodeClient ,
88} from '@opencode-ai/sdk' ;
9+ import type { ToolPart } from '@opencode-ai/sdk' ;
910import type { BridgeAdapter } from '../types' ;
1011import type { AdapterMux } from './mux' ;
1112import { bridgeLogger } from '../logger' ;
@@ -31,12 +32,20 @@ import {
3132 shouldSplitOutFinalAnswer ,
3233 splitFinalAnswerFromExecution ,
3334} from './execution.flow' ;
35+ import {
36+ extractQuestionPayload ,
37+ isQuestionToolPart ,
38+ QUESTION_TIMEOUT_MS ,
39+ renderQuestionPrompt ,
40+ } from './question.proxy' ;
41+ import type { PendingQuestionState , NormalizedQuestionPayload } from './question.proxy' ;
3442
3543type SessionContext = { chatId : string ; senderId : string } ;
3644type SelectedModel = { providerID : string ; modelID : string ; name ?: string } ;
3745type ListenerState = { isListenerStarted : boolean ; shouldStopListener : boolean } ;
3846type EventWithType = { type : string ; properties ?: unknown } ;
3947type EventMessageBuffer = MessageBuffer & { __executionCarried ?: boolean } ;
48+ const QUESTION_DEBUG_MAX_LEN = 4000 ;
4049
4150export type EventFlowDeps = {
4251 listenerState : ListenerState ;
@@ -53,8 +62,110 @@ export type EventFlowDeps = {
5362 chatAwaitingSaveFile : Map < string , boolean > ;
5463 chatMaxFileSizeMb : Map < string , number > ;
5564 chatMaxFileRetry : Map < string , number > ;
65+ chatPendingQuestion : Map < string , PendingQuestionState > ;
66+ pendingQuestionTimers : Map < string , NodeJS . Timeout > ;
5667} ;
5768
69+ function clearPendingQuestionForChat ( deps : EventFlowDeps , cacheKey : string ) {
70+ const timer = deps . pendingQuestionTimers . get ( cacheKey ) ;
71+ if ( timer ) {
72+ clearTimeout ( timer ) ;
73+ deps . pendingQuestionTimers . delete ( cacheKey ) ;
74+ }
75+ deps . chatPendingQuestion . delete ( cacheKey ) ;
76+ }
77+
78+ function clearAllPendingQuestions ( deps : EventFlowDeps ) {
79+ for ( const timer of deps . pendingQuestionTimers . values ( ) ) {
80+ clearTimeout ( timer ) ;
81+ }
82+ deps . pendingQuestionTimers . clear ( ) ;
83+ deps . chatPendingQuestion . clear ( ) ;
84+ }
85+
86+ function getCacheKeyBySession (
87+ sessionId : string ,
88+ deps : EventFlowDeps ,
89+ ) : { cacheKey : string ; adapterKey : string ; chatId : string } | null {
90+ const ctx = deps . sessionToCtx . get ( sessionId ) ;
91+ const adapterKey = deps . sessionToAdapterKey . get ( sessionId ) ;
92+ if ( ! ctx || ! adapterKey ) return null ;
93+ return {
94+ cacheKey : `${ adapterKey } :${ ctx . chatId } ` ,
95+ adapterKey,
96+ chatId : ctx . chatId ,
97+ } ;
98+ }
99+
100+ function clipDebugText ( value : string , max = QUESTION_DEBUG_MAX_LEN ) : string {
101+ if ( value . length <= max ) return value ;
102+ return `${ value . slice ( 0 , max ) } ...<truncated>` ;
103+ }
104+
105+ async function captureQuestionProxyIfNeeded ( params : {
106+ part : ToolPart ;
107+ sessionId : string ;
108+ messageId : string ;
109+ api : OpencodeClient ;
110+ mux : AdapterMux ;
111+ deps : EventFlowDeps ;
112+ } ) : Promise < boolean > {
113+ const { part, sessionId, messageId, api, mux, deps } = params ;
114+ if ( ! isQuestionToolPart ( part ) ) return false ;
115+
116+ const payloadMaybe = extractQuestionPayload ( part ?. state ?. input ) ;
117+ if ( payloadMaybe === null ) return false ;
118+ const payload : NormalizedQuestionPayload = payloadMaybe ;
119+
120+ const sessionCtx = getCacheKeyBySession ( sessionId , deps ) ;
121+
122+ if ( ! sessionCtx ) return false ;
123+
124+ const { cacheKey, adapterKey, chatId } = sessionCtx ;
125+ const callID = part . callID || `question-${ messageId } ` ;
126+ const existing = deps . chatPendingQuestion . get ( cacheKey ) ;
127+ if ( existing && existing . callID === callID && existing . messageId === messageId ) {
128+ return true ;
129+ }
130+
131+ clearPendingQuestionForChat ( deps , cacheKey ) ;
132+
133+ const pending : PendingQuestionState = {
134+ key : cacheKey ,
135+ adapterKey,
136+ chatId,
137+ sessionId,
138+ messageId,
139+ callID,
140+ payload,
141+ createdAt : Date . now ( ) ,
142+ dueAt : Date . now ( ) + QUESTION_TIMEOUT_MS ,
143+ } ;
144+
145+ deps . chatPendingQuestion . set ( cacheKey , pending ) ;
146+
147+ const adapter = mux . get ( adapterKey ) ;
148+ if ( adapter ) {
149+ await adapter . sendMessage ( chatId , renderQuestionPrompt ( pending ) ) . catch ( ( ) => { } ) ;
150+ }
151+
152+ const timer = setTimeout ( async ( ) => {
153+ const current = deps . chatPendingQuestion . get ( cacheKey ) ;
154+ if ( ! current || current . callID !== callID || current . messageId !== messageId ) return ;
155+
156+ clearPendingQuestionForChat ( deps , cacheKey ) ;
157+ const currentAdapter = mux . get ( current . adapterKey ) ;
158+ if ( currentAdapter ) {
159+ await currentAdapter
160+ . sendMessage ( current . chatId , '## Status\n⏰ 超时,本轮提问已取消。请重新发起问题。' )
161+ . catch ( ( ) => { } ) ;
162+ }
163+ } , QUESTION_TIMEOUT_MS ) ;
164+
165+ deps . pendingQuestionTimers . set ( cacheKey , timer ) ;
166+ return true ;
167+ }
168+
58169function isAbortedError ( err : unknown ) : boolean {
59170 return (
60171 typeof err === 'object' &&
@@ -145,6 +256,15 @@ async function handleMessageUpdatedEvent(
145256 }
146257
147258 if ( info . error ) {
259+ const cache = getCacheKeyBySession ( sid , deps ) ;
260+ const pending = cache ? deps . chatPendingQuestion . get ( cache . cacheKey ) : undefined ;
261+
262+ if ( pending && pending . messageId === mid ) {
263+ markStatus ( deps . msgBuffers , mid , 'done' , 'awaiting-user-reply' ) ;
264+ await flushMessage ( adapter , ctx . chatId , mid , deps . msgBuffers , true ) ;
265+ return ;
266+ }
267+
148268 if ( isAbortedError ( info . error ) ) {
149269 markStatus (
150270 deps . msgBuffers ,
@@ -181,6 +301,7 @@ async function handleMessageUpdatedEvent(
181301
182302async function handleMessagePartUpdatedEvent (
183303 event : EventMessagePartUpdated ,
304+ api : OpencodeClient ,
184305 mux : AdapterMux ,
185306 deps : EventFlowDeps ,
186307) {
@@ -225,6 +346,21 @@ async function handleMessagePartUpdatedEvent(
225346 buffer . selectedModel = selectedModel ;
226347 }
227348 applyPartToBuffer ( buffer , part , delta ) ;
349+
350+ if (
351+ part . type === 'tool' &&
352+ ( await captureQuestionProxyIfNeeded ( {
353+ part : part as ToolPart ,
354+ sessionId,
355+ messageId,
356+ api,
357+ mux,
358+ deps,
359+ } ) )
360+ ) {
361+ markStatus ( deps . msgBuffers , messageId , 'done' , 'awaiting-user-reply' ) ;
362+ }
363+
228364 bridgeLogger . debug (
229365 `[BridgeFlowDebug] part-applied sid=${ sessionId } mid=${ messageId } part=${ part . type } textLen=${ buffer . text . length } reasoningLen=${ buffer . reasoning . length } tools=${ buffer . tools . size } status=${ buffer . status } note="${ buffer . statusNote || '' } " hasPlatform=${ ! ! buffer . platformMsgId } ` ,
230366 ) ;
@@ -394,7 +530,7 @@ export async function startGlobalEventListenerWithDeps(
394530 bridgeLogger . debug (
395531 `[BridgeFlowDebug] part.updated sid=${ p . sessionID } mid=${ p . messageID } type=${ p . type } deltaLen=${ ( pe . properties . delta || '' ) . length } ` ,
396532 ) ;
397- await handleMessagePartUpdatedEvent ( event as EventMessagePartUpdated , mux , deps ) ;
533+ await handleMessagePartUpdatedEvent ( event as EventMessagePartUpdated , api , mux , deps ) ;
398534 continue ;
399535 }
400536
@@ -447,4 +583,6 @@ export function stopGlobalEventListenerWithDeps(deps: EventFlowDeps) {
447583 deps . chatAwaitingSaveFile . clear ( ) ;
448584 deps . chatMaxFileSizeMb . clear ( ) ;
449585 deps . chatMaxFileRetry . clear ( ) ;
586+
587+ clearAllPendingQuestions ( deps ) ;
450588}
0 commit comments