@@ -165,10 +165,22 @@ class SummaryAggregator {
165165 private onSessionDiffCallback : SessionDiffCallback | null = null ;
166166 private onFileChangeCallback : FileChangeCallback | null = null ;
167167 private onClearedCallback : ClearedCallback | null = null ;
168+ // Optional async lookup to ask the opencode server "which partIDs in this
169+ // message are type=reasoning?". Used at session.idle to strip reasoning
170+ // text from the rendered final message — see comment in handleSessionIdle.
171+ private messagePartTypeLookup :
172+ | ( ( sessionID : string , messageID : string ) => Promise < Map < string , string > > )
173+ | null = null ;
168174 private processedToolStates : Set < string > = new Set ( ) ;
169175 private thinkingFiredForMessages : Set < string > = new Set ( ) ;
170176 private deliveredExternalUserMessageIds : Set < string > = new Set ( ) ;
171177 private knownTextPartIds : Map < string , Set < string > > = new Map ( ) ;
178+ // Parts confirmed as reasoning by message.part.updated. Deltas for these
179+ // partIDs are dropped (they're "thinking" content, not user-facing).
180+ // Required because v1.15 emits deltas before the type-discriminator
181+ // updated event for short reasoning streams — we tentatively apply as
182+ // text, then rectify here once the updated arrives.
183+ private knownReasoningPartIds : Map < string , Set < string > > = new Map ( ) ;
172184 private bot : Bot | null = null ;
173185 private chatId : number | null = null ;
174186 private typingTimer : ReturnType < typeof setInterval > | null = null ;
@@ -191,6 +203,12 @@ class SummaryAggregator {
191203 this . onCompleteCallback = callback ;
192204 }
193205
206+ setMessagePartTypeLookup (
207+ fn : ( sessionID : string , messageID : string ) => Promise < Map < string , string > > ,
208+ ) : void {
209+ this . messagePartTypeLookup = fn ;
210+ }
211+
194212 setOnPartial ( callback : MessagePartialCallback ) : void {
195213 this . onPartialCallback = callback ;
196214 }
@@ -385,6 +403,7 @@ class SummaryAggregator {
385403 this . messages . clear ( ) ;
386404 this . partHashes . clear ( ) ;
387405 this . knownTextPartIds . clear ( ) ;
406+ this . knownReasoningPartIds . clear ( ) ;
388407 this . processedToolStates . clear ( ) ;
389408 this . thinkingFiredForMessages . clear ( ) ;
390409 this . deliveredExternalUserMessageIds . clear ( ) ;
@@ -651,6 +670,13 @@ class SummaryAggregator {
651670 }
652671
653672 if ( part . type === "reasoning" ) {
673+ // Confirm this partID is reasoning so future deltas for it get dropped.
674+ this . registerKnownReasoningPart ( messageID , part . id ) ;
675+ // Rectify: if deltas arrived before this update and were optimistically
676+ // applied as text, remove them now. Without this, short reasoning leaks
677+ // into the user-facing chat (issue with qwen, deepseek, sonnet w/o
678+ // extended thinking).
679+ this . unapplyMistakenlyTextPart ( part . sessionID , messageID , part . id ) ;
654680 // Fire the thinking callback once per message on the first reasoning part.
655681 // This is the signal that the model is actually doing extended thinking.
656682 if ( ! this . thinkingFiredForMessages . has ( messageID ) && this . onThinkingCallback ) {
@@ -797,6 +823,15 @@ class SummaryAggregator {
797823 // `knownTextPartIds`, which `handleMessagePartUpdated` populates from
798824 // `message.part.updated.properties.part.type === "text"`. That is the
799825 // real Part-type discriminator and fires before deltas for the same part.
826+ // EDGE CASE: with short reasoning streams (qwen, deepseek, Claude Sonnet
827+ // in non-thinking mode), the deltas can arrive before the
828+ // `message.part.updated` that says `type=reasoning`. We optimistically
829+ // apply them as text below, then `handleMessagePartUpdated` rectifies
830+ // by removing the wrongly-accumulated text when it confirms reasoning.
831+ const knownReasoningIds = this . knownReasoningPartIds . get ( messageID ) ;
832+ if ( knownReasoningIds ?. has ( partID ) ) {
833+ return ; // already confirmed as reasoning — drop
834+ }
800835 const knownTextIds = this . knownTextPartIds . get ( messageID ) ;
801836 const isKnownTextPart = knownTextIds ?. has ( partID ) ?? false ;
802837 const thinkingFired = this . thinkingFiredForMessages . has ( messageID ) ;
@@ -886,6 +921,7 @@ class SummaryAggregator {
886921 this . messages . delete ( messageId ) ;
887922 this . partHashes . delete ( messageId ) ;
888923 this . knownTextPartIds . delete ( messageId ) ;
924+ this . knownReasoningPartIds . delete ( messageId ) ;
889925
890926 if ( this . textMessageStates . size === 0 ) {
891927 logger . debug ( "[Aggregator] No more active messages, stopping typing indicator" ) ;
@@ -928,6 +964,48 @@ class SummaryAggregator {
928964 this . knownTextPartIds . get ( messageID ) ! . add ( partID ) ;
929965 }
930966
967+ private registerKnownReasoningPart ( messageID : string , partID : string ) : void {
968+ if ( ! this . knownReasoningPartIds . has ( messageID ) ) {
969+ this . knownReasoningPartIds . set ( messageID , new Set ( ) ) ;
970+ }
971+
972+ this . knownReasoningPartIds . get ( messageID ) ! . add ( partID ) ;
973+ }
974+
975+ /**
976+ * Undo the optimistic "applied as text" of a partID we now know was
977+ * reasoning. Strips it from textMessageStates and re-emits the corrected
978+ * combined text. Idempotent — safe to call when the part was never
979+ * applied as text (early return on no-op).
980+ */
981+ private unapplyMistakenlyTextPart (
982+ sessionID : string ,
983+ messageID : string ,
984+ partID : string ,
985+ ) : void {
986+ const textIds = this . knownTextPartIds . get ( messageID ) ;
987+ const wasTreatedAsText = textIds ?. has ( partID ) ?? false ;
988+ if ( wasTreatedAsText ) {
989+ textIds ! . delete ( partID ) ;
990+ }
991+
992+ const state = this . textMessageStates . get ( messageID ) ;
993+ if ( ! state ) return ;
994+
995+ const hadPart = state . partTexts . has ( partID ) || state . orderedPartIds . includes ( partID ) ;
996+ if ( ! hadPart ) return ;
997+
998+ state . partTexts . delete ( partID ) ;
999+ state . orderedPartIds = state . orderedPartIds . filter ( ( id ) => id !== partID ) ;
1000+
1001+ // Re-emit corrected partial. If combined is now empty, the streamer
1002+ // gates on `messageText.trim()` and skips the edit — no harm done.
1003+ const messageInfo = this . messages . get ( messageID ) ;
1004+ if ( messageInfo ?. role !== "assistant" ) return ;
1005+ const combined = this . getCombinedMessageText ( messageID ) ;
1006+ this . emitPartialText ( sessionID , messageID , combined ) ;
1007+ }
1008+
9311009 private registerTextPart ( messageID : string , partID : string ) : void {
9321010 const state = this . getOrCreateTextMessageState ( messageID ) ;
9331011 if ( ! state . orderedPartIds . includes ( partID ) ) {
@@ -1161,23 +1239,66 @@ class SummaryAggregator {
11611239 // este flush, `onCompleteCallback` no se dispararía nunca y todo lo que
11621240 // dependa de él (TTS, footer con modelo/agent, run-state cleanup) queda
11631241 // huérfano. Drenamos cualquier mensaje acumulado en textMessageStates aquí.
1242+ //
1243+ // ANTES de drenar, si tenemos un lookup configurado, le preguntamos al
1244+ // server qué partes son `reasoning` y las stripamos. El SDK NO emite
1245+ // `message.part.updated` con type=reasoning en tiempo real (al menos para
1246+ // razonamientos cortos), así que el aggregator no puede discriminarlas
1247+ // por sí solo — todos los deltas llegan con `field=text` y `type=?`. El
1248+ // server SÍ las tiene categorizadas en su storage.
11641249 if ( this . onCompleteCallback && this . textMessageStates . size > 0 ) {
1165- for ( const messageID of Array . from ( this . textMessageStates . keys ( ) ) ) {
1166- const finalText = this . getCombinedMessageText ( messageID ) ;
1167- if ( ! finalText . trim ( ) ) {
1250+ const messagesToFlush = Array . from ( this . textMessageStates . keys ( ) ) ;
1251+ const callback = this . onCompleteCallback ;
1252+ const lookup = this . messagePartTypeLookup ;
1253+ const flushImpl = async ( ) => {
1254+ for ( const messageID of messagesToFlush ) {
1255+ if ( lookup ) {
1256+ try {
1257+ const partTypes = await lookup ( sessionID , messageID ) ;
1258+ for ( const [ partID , type ] of partTypes ) {
1259+ if ( type === "reasoning" ) {
1260+ this . unapplyMistakenlyTextPart ( sessionID , messageID , partID ) ;
1261+ }
1262+ }
1263+ } catch ( err ) {
1264+ logger . warn (
1265+ `[Aggregator] Reasoning strip lookup failed for ${ messageID } ; will deliver as-is:` ,
1266+ err ,
1267+ ) ;
1268+ }
1269+ }
1270+ const finalText = this . getCombinedMessageText ( messageID ) ;
1271+ if ( ! finalText . trim ( ) ) {
1272+ this . cleanupCompletedMessage ( messageID ) ;
1273+ continue ;
1274+ }
1275+ logger . debug (
1276+ `[Aggregator] Flushing pending assistant message on session.idle: messageId=${ messageID } , textLength=${ finalText . length } ` ,
1277+ ) ;
1278+ try {
1279+ callback ( sessionID , messageID , finalText , { } ) ;
1280+ } catch ( err ) {
1281+ logger . error ( "[Aggregator] Error in onComplete during idle flush:" , err ) ;
1282+ }
11681283 this . cleanupCompletedMessage ( messageID ) ;
1169- continue ;
11701284 }
1171- logger . debug (
1172- `[Aggregator] Flushing pending assistant message on session.idle: messageId=${ messageID } , textLength=${ finalText . length } ` ,
1173- ) ;
1174- try {
1175- this . onCompleteCallback ( sessionID , messageID , finalText , { } ) ;
1176- } catch ( err ) {
1177- logger . error ( "[Aggregator] Error in onComplete during idle flush:" , err ) ;
1178- }
1179- this . cleanupCompletedMessage ( messageID ) ;
1180- }
1285+ } ;
1286+ // El idle callback DEBE dispararse DESPUÉS de que el flush termine,
1287+ // porque el handler de TTS depende del texto acumulado durante
1288+ // onCompleteCallback (waitForIdle mode). Si fuera al revés, el idle
1289+ // callback corre flushTtsText antes de que onComplete acumule —
1290+ // resultado: no se envía audio.
1291+ const idleCb = this . onSessionIdleCallback ;
1292+ flushImpl ( )
1293+ . catch ( ( err ) => {
1294+ logger . error ( "[Aggregator] Idle flush failed:" , err ) ;
1295+ } )
1296+ . finally ( ( ) => {
1297+ if ( idleCb ) {
1298+ setImmediate ( ( ) => idleCb ( sessionID ) ) ;
1299+ }
1300+ } ) ;
1301+ return ;
11811302 }
11821303
11831304 if ( this . onSessionIdleCallback ) {
0 commit comments