@@ -103,6 +103,8 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
103103 private readonly TimeSpan _lastChatStateSaveDelay ;
104104 private readonly Func < TimeSpan , CancellationToken , Func < Task > , Task > _scheduleHistoryRetry ;
105105 private readonly ThinkingLevelClearReconciler _thinkingLevelClearReconciler ;
106+ private readonly Dictionary < string , long > _confirmedNullThinkingAuthorityTokens =
107+ new ( StringComparer . Ordinal ) ;
106108 private readonly Action ? _historyFailureReservedForTesting ;
107109 private System . Threading . Timer ? _toolMetaSaveTimer ; // debounce cache writes
108110 private long _toolMetaSaveVersion ;
@@ -223,6 +225,7 @@ private enum AssistantQueueFrameDisposition
223225 // was already in flight at disconnect time is discarded on completion rather
224226 // than resurrecting a catalog for a stale connection.
225227 private int _commandsEpoch ;
228+ private long _nextConfirmedNullThinkingAuthorityToken ;
226229 private ConnectionStatus _status ;
227230 private bool _disposed ;
228231
@@ -335,26 +338,34 @@ public Task<ChatDataSnapshot> LoadAsync(CancellationToken cancellationToken = de
335338 ChatDataSnapshot snapshot ;
336339 lock ( _gate )
337340 {
341+ ReleaseMissingConfirmedNullThinkingAuthoritiesLocked ( sessions ) ;
338342 _sessions = sessions . Select ( session =>
339343 {
340344 if ( string . IsNullOrEmpty ( session . Key ) )
341345 return session ;
342346
347+ var projectedSession = ApplyConfirmedNullThinkingAuthorityLocked (
348+ session ,
349+ releaseOnNull : true ) ;
343350 var resolution = _thinkingLevelClearReconciler . ObserveSnapshot (
344- session . Key ,
345- session . ThinkingLevel ) ;
351+ projectedSession . Key ,
352+ projectedSession . ThinkingLevel ) ;
346353 if ( resolution . RefreshRequest is { } refresh )
347354 ( thinkingLevelsToRefresh ??= [ ] ) . Add ( refresh ) ;
355+ var effectiveThinkingLevel =
356+ _confirmedNullThinkingAuthorityTokens . ContainsKey ( projectedSession . Key )
357+ ? null
358+ : resolution . EffectiveThinkingLevel ;
348359 if ( string . Equals (
349- session . ThinkingLevel ,
350- resolution . EffectiveThinkingLevel ,
360+ projectedSession . ThinkingLevel ,
361+ effectiveThinkingLevel ,
351362 StringComparison . Ordinal ) )
352363 {
353- return session ;
364+ return projectedSession ;
354365 }
355366
356- var reconciled = session . Clone ( ) ;
357- reconciled . ThinkingLevel = resolution . EffectiveThinkingLevel ;
367+ var reconciled = projectedSession . Clone ( ) ;
368+ reconciled . ThinkingLevel = effectiveThinkingLevel ;
358369 return reconciled ;
359370 } ) . ToArray ( ) ;
360371 EnsureTimelinesForSessionsLocked ( ) ;
@@ -1823,11 +1834,16 @@ public async Task SetThinkingLevelAsync(string threadId, string thinkingLevel, C
18231834 {
18241835 cancellationToken . ThrowIfCancellationRequested ( ) ;
18251836 ThinkingLevelClearReconciler . ConcreteSelection selection ;
1837+ long ? confirmedNullAuthorityToken ;
18261838 lock ( _gate )
18271839 {
18281840 var canonicalThinkingLevel = _sessions
18291841 . FirstOrDefault ( session => string . Equals ( session . Key , threadId , StringComparison . Ordinal ) )
18301842 ? . ThinkingLevel ;
1843+ confirmedNullAuthorityToken =
1844+ _confirmedNullThinkingAuthorityTokens . TryGetValue ( threadId , out var token )
1845+ ? token
1846+ : null ;
18311847 selection = _thinkingLevelClearReconciler . BeginConcreteSelection (
18321848 threadId ,
18331849 thinkingLevel ,
@@ -1837,6 +1853,20 @@ public async Task SetThinkingLevelAsync(string threadId, string thinkingLevel, C
18371853 try
18381854 {
18391855 await _bridge . PatchSessionThinkingLevelAsync ( threadId , thinkingLevel ) ;
1856+ if ( confirmedNullAuthorityToken is { } expectedToken )
1857+ {
1858+ lock ( _gate )
1859+ {
1860+ if ( ! _disposed &&
1861+ _confirmedNullThinkingAuthorityTokens . TryGetValue (
1862+ threadId ,
1863+ out var currentToken ) &&
1864+ currentToken == expectedToken )
1865+ {
1866+ _confirmedNullThinkingAuthorityTokens . Remove ( threadId ) ;
1867+ }
1868+ }
1869+ }
18401870 }
18411871 catch
18421872 {
@@ -1857,6 +1887,7 @@ public async Task ClearThinkingLevelAsync(string threadId, CancellationToken can
18571887 operation = _thinkingLevelClearReconciler . BeginClear (
18581888 threadId ,
18591889 canonicalThinkingLevel ) ;
1890+ ObserveBackgroundTask ( operation . Confirmation ) ;
18601891 }
18611892
18621893 try
@@ -2243,6 +2274,7 @@ public ValueTask DisposeAsync()
22432274 _localSentTexts . Clear ( ) ;
22442275 _locallyInitiatedThreads . Clear ( ) ;
22452276 _resetSubmittedLocalEchoTexts . Clear ( ) ;
2277+ _confirmedNullThinkingAuthorityTokens . Clear ( ) ;
22462278 }
22472279 _thinkingLevelClearReconciler . Dispose ( ) ;
22482280 CancelAndDisposeHistoryGeneration ( historyGenerationToCancel ) ;
@@ -2290,6 +2322,9 @@ private void OnStatusChanged(object? sender, ConnectionStatus status)
22902322
22912323 justReconnected = status == ConnectionStatus . Connected
22922324 && _status != ConnectionStatus . Connected ;
2325+ var connectedAuthorityChanged =
2326+ ( status == ConnectionStatus . Connected ) !=
2327+ ( _status == ConnectionStatus . Connected ) ;
22932328 // MEDIUM 5: detect Connected → Disconnected/Error transitions so
22942329 // we can synthesise a turn-end + status entry on every thread that
22952330 // had an in-flight turn (otherwise the UI sits "thinking" forever).
@@ -2298,6 +2333,8 @@ private void OnStatusChanged(object? sender, ConnectionStatus status)
22982333 _status = status ;
22992334 thinkingLevelsToRefresh = _thinkingLevelClearReconciler
23002335 . OnConnectionChanged ( status == ConnectionStatus . Connected ) ;
2336+ if ( connectedAuthorityChanged )
2337+ _confirmedNullThinkingAuthorityTokens . Clear ( ) ;
23012338
23022339 // Reset the sessions-list-received gate whenever we leave the
23032340 // Connected state. Any cached sessions belong to the previous
@@ -2453,10 +2490,25 @@ private bool ApplySessionsUpdated(
24532490 return false ;
24542491
24552492 correlatedResolution = resolution ;
2493+ if ( resolution . State == ThinkingLevelClearReconciler . ReconciliationState . Confirmed )
2494+ {
2495+ if ( resolution . EffectiveThinkingLevel is null )
2496+ {
2497+ _confirmedNullThinkingAuthorityTokens [ correlatedRequest . ThreadId ] =
2498+ ++ _nextConfirmedNullThinkingAuthorityToken ;
2499+ }
2500+ else
2501+ {
2502+ _confirmedNullThinkingAuthorityTokens . Remove ( correlatedRequest . ThreadId ) ;
2503+ }
2504+ }
24562505 if ( resolution . RefreshRequest is { } retry )
24572506 ( thinkingLevelsToRefresh ??= [ ] ) . Add ( retry ) ;
24582507 }
24592508
2509+ if ( refreshRequest is null )
2510+ ReleaseMissingConfirmedNullThinkingAuthoritiesLocked ( incomingSessions ) ;
2511+
24602512 var previousThreads = refreshRequest is null
24612513 ? null
24622514 : BuildSnapshotLocked ( ) . Threads ;
@@ -2467,32 +2519,44 @@ private bool ApplySessionsUpdated(
24672519 for ( var index = 0 ; index < incomingSessions . Length ; index ++ )
24682520 {
24692521 var incoming = incomingSessions [ index ] ;
2470- reconciledSessions [ index ] = incoming ;
2522+ var isCorrelatedTarget =
2523+ refreshRequest is { } request &&
2524+ string . Equals ( request . ThreadId , incoming . Key , StringComparison . Ordinal ) ;
2525+ var projectedSession = isCorrelatedTarget
2526+ ? incoming
2527+ : ApplyConfirmedNullThinkingAuthorityLocked (
2528+ incoming ,
2529+ releaseOnNull : refreshRequest is null ) ;
2530+ reconciledSessions [ index ] = projectedSession ;
24712531 if ( string . IsNullOrEmpty ( incoming . Key ) )
24722532 continue ;
24732533
24742534 ThinkingLevelClearReconciler . SnapshotResolution resolution ;
2475- if ( refreshRequest is { } request &&
2476- string . Equals ( request . ThreadId , incoming . Key , StringComparison . Ordinal ) )
2535+ if ( isCorrelatedTarget )
24772536 {
24782537 resolution = correlatedResolution ! . Value ;
24792538 }
24802539 else
24812540 {
24822541 resolution = _thinkingLevelClearReconciler . ObserveSnapshot (
2483- incoming . Key ,
2484- incoming . ThinkingLevel ) ;
2542+ projectedSession . Key ,
2543+ projectedSession . ThinkingLevel ) ;
24852544 if ( resolution . RefreshRequest is { } refresh )
24862545 ( thinkingLevelsToRefresh ??= [ ] ) . Add ( refresh ) ;
24872546 }
24882547
2548+ var effectiveThinkingLevel =
2549+ ! isCorrelatedTarget &&
2550+ _confirmedNullThinkingAuthorityTokens . ContainsKey ( projectedSession . Key )
2551+ ? null
2552+ : resolution . EffectiveThinkingLevel ;
24892553 if ( ! string . Equals (
2490- incoming . ThinkingLevel ,
2491- resolution . EffectiveThinkingLevel ,
2554+ projectedSession . ThinkingLevel ,
2555+ effectiveThinkingLevel ,
24922556 StringComparison . Ordinal ) )
24932557 {
2494- var reconciled = incoming . Clone ( ) ;
2495- reconciled . ThinkingLevel = resolution . EffectiveThinkingLevel ;
2558+ var reconciled = projectedSession . Clone ( ) ;
2559+ reconciled . ThinkingLevel = effectiveThinkingLevel ;
24962560 reconciledSessions [ index ] = reconciled ;
24972561 }
24982562 }
@@ -2540,6 +2604,45 @@ private bool ApplySessionsUpdated(
25402604 return true ;
25412605 }
25422606
2607+ private SessionInfo ApplyConfirmedNullThinkingAuthorityLocked (
2608+ SessionInfo session ,
2609+ bool releaseOnNull )
2610+ {
2611+ if ( string . IsNullOrEmpty ( session . Key ) ||
2612+ ! _confirmedNullThinkingAuthorityTokens . ContainsKey ( session . Key ) )
2613+ {
2614+ return session ;
2615+ }
2616+
2617+ if ( session . ThinkingLevel is null )
2618+ {
2619+ if ( releaseOnNull )
2620+ _confirmedNullThinkingAuthorityTokens . Remove ( session . Key ) ;
2621+ return session ;
2622+ }
2623+
2624+ var projected = session . Clone ( ) ;
2625+ projected . ThinkingLevel = null ;
2626+ return projected ;
2627+ }
2628+
2629+ private void ReleaseMissingConfirmedNullThinkingAuthoritiesLocked (
2630+ IReadOnlyCollection < SessionInfo > sessions )
2631+ {
2632+ if ( _confirmedNullThinkingAuthorityTokens . Count == 0 )
2633+ return ;
2634+
2635+ var incomingKeys = sessions
2636+ . Where ( session => ! string . IsNullOrEmpty ( session . Key ) )
2637+ . Select ( session => session . Key )
2638+ . ToHashSet ( StringComparer . Ordinal ) ;
2639+ var missingThreads = _confirmedNullThinkingAuthorityTokens . Keys
2640+ . Where ( threadId => ! incomingKeys . Contains ( threadId ) )
2641+ . ToArray ( ) ;
2642+ foreach ( var threadId in missingThreads )
2643+ _confirmedNullThinkingAuthorityTokens . Remove ( threadId ) ;
2644+ }
2645+
25432646 internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload (
25442647 ChatEntryMetadata ? metadata ,
25452648 int maxHistorySequence ,
0 commit comments