diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 8539bfab1..bac59d1bc 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -234,6 +234,12 @@ protected override void OnError(Exception ex) public event EventHandler? ActivityChanged; public event EventHandler? ChannelHealthUpdated; public event EventHandler? SessionsUpdated; + /// + /// Raised only for fresh sessions.list responses. Unlike + /// , this event is not raised when a tool or + /// job activity update republishes the cached session collection. + /// + public event EventHandler? SessionUsageSnapshotUpdated; public event EventHandler? UsageUpdated; public event EventHandler? UsageStatusUpdated; public event EventHandler? UsageCostUpdated; @@ -4401,6 +4407,7 @@ private void ParseSessions(JsonElement sessions) } SessionsUpdated?.Invoke(this, snapshot); + SessionUsageSnapshotUpdated?.Invoke(this, snapshot); } catch (Exception ex) { diff --git a/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs b/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs index 445b39d72..217c22aff 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs @@ -238,6 +238,11 @@ public event EventHandler? SessionsUpdated add { } remove { } } + public event EventHandler? SessionUsageSnapshotUpdated + { + add { } + remove { } + } public event EventHandler? SessionCommandCompleted diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs index b88285bf6..318536565 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs @@ -354,29 +354,11 @@ internal ChatSessionsTransition ApplySessions( { if (_disposed) return new(BuildSnapshotLocked(context), []); - var previousUsage = _presentation.SnapshotUsage(); _presentation.ReplaceSessions(sessions); var currentSessions = _presentation.SessionSnapshot(); _history.SeedSessionIds(currentSessions); EnsureTimelinesForSessionsLocked(); _presentation.RememberLastSessionState(context); - foreach (var session in currentSessions) - { - if (string.IsNullOrEmpty(session.Key)) - continue; - var usage = new ChatUsageSnapshot( - session.InputTokens, - session.OutputTokens, - session.TotalTokens, - session.ContextTokens); - if (!previousUsage.TryGetValue(session.Key, out var previous) || - previous != usage) - { - SnapshotLatestAssistantUsageLocked( - session, - _presentation.ResolveTimelineKey(session, _timelines)); - } - } return new( BuildSnapshotLocked(context), _status == ConnectionStatus.Connected @@ -385,6 +367,28 @@ internal ChatSessionsTransition ApplySessions( } } + internal ChatDataSnapshot? ApplyAuthoritativeSessionUsage( + SessionInfo[] sessions, + ChatProjectionContext context) + { + lock (_gate) + { + if (_disposed) + return null; + var changed = false; + foreach (var session in sessions) + { + if (string.IsNullOrEmpty(session.Key)) + continue; + changed |= SnapshotLatestAssistantUsageLocked( + session, + _presentation.ResolveTimelineKey(session, _timelines), + authoritative: true); + } + return changed ? BuildSnapshotLocked(context) : null; + } + } + internal ChatDataSnapshot ApplyModels( ModelsListInfo models, ChatProjectionContext context) @@ -489,7 +493,8 @@ internal void ActivateHistoryGeneration(long generation) private bool SnapshotLatestAssistantUsageLocked( SessionInfo session, - string threadId) + string threadId, + bool authoritative) { if (string.IsNullOrEmpty(session.Key)) return false; @@ -508,16 +513,41 @@ private bool SnapshotLatestAssistantUsageLocked( continue; var metadata = GetOrCreateThreadMetaLocked(threadId); metadata.TryGetValue(timeline.Entries[i].Id, out var existing); - var usageSnapshot = Math.Max( - usedTokens, - existing?.ResponseTokens ?? 0); + if (!authoritative && + existing?.UsageContributionTokens is > 0) + { + var contributionContextTokens = session.ContextTokens > 0 + ? session.ContextTokens + : existing.ContextTokens; + if (existing.ContextTokens == contributionContextTokens) + return false; + metadata[timeline.Entries[i].Id] = existing with + { + ContextTokens = contributionContextTokens, + ContextPercent = null, + }; + return true; + } + var usageSnapshot = authoritative + ? usedTokens + : Math.Max(usedTokens, existing?.ResponseTokens ?? 0); var usageTokens = ToIntIfPositive(usageSnapshot); var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : existing?.ContextTokens; + var contextPercent = existing?.ContextPercent; + if (authoritative || + existing?.ResponseTokens != usageTokens || + existing?.ContextTokens != contextTokens) + { + contextPercent = null; + } + var usageContributionTokens = existing?.UsageContributionTokens; if (existing is not null && existing.ResponseTokens == usageTokens && - existing.ContextTokens == contextTokens) + existing.ContextTokens == contextTokens && + existing.ContextPercent == contextPercent && + existing.UsageContributionTokens == usageContributionTokens) { return false; } @@ -528,8 +558,8 @@ private bool SnapshotLatestAssistantUsageLocked( OutputTokens = ToIntIfPositive(session.OutputTokens), ResponseTokens = usageTokens, ContextTokens = contextTokens, - ContextPercent = existing?.ContextPercent, - UsageContributionTokens = existing?.UsageContributionTokens, + ContextPercent = contextPercent, + UsageContributionTokens = usageContributionTokens, }; return true; } @@ -2193,7 +2223,7 @@ private static bool TryGetTerminalAgentRunId( threadId, context.MainSessionKey); return session is not null && - SnapshotLatestAssistantUsageLocked(session, threadId) + SnapshotLatestAssistantUsageLocked(session, threadId, authoritative: false) ? BuildSnapshotLocked(context) : null; } @@ -2232,14 +2262,18 @@ private bool SnapshotAssistantUsageContributionLocked( continue; var threadMetadata = GetOrCreateThreadMetaLocked(threadId); threadMetadata.TryGetValue(entry.Id, out var existing); - var previousUsage = LatestAssistantUsageBeforeLocked( - timeline, - threadMetadata, - i); - var cumulative = Math.Max( - (previousUsage ?? 0) + currentUsage.Value, - existing?.ResponseTokens ?? 0); - if (existing?.ResponseTokens == cumulative && + if (existing?.UsageContributionTokens == currentUsage && + existing.ResponseTokens != currentUsage) + return false; + var usageSnapshot = currentUsage.Value; + var contextPercent = metadata.ContextPercent; + if (contextPercent is null && + existing?.ResponseTokens == usageSnapshot && + existing.ContextTokens == contextTokens) + { + contextPercent = existing.ContextPercent; + } + if (existing?.ResponseTokens == usageSnapshot && existing.UsageContributionTokens == currentUsage && existing.ContextTokens == contextTokens) { @@ -2249,8 +2283,8 @@ private bool SnapshotAssistantUsageContributionLocked( { InputTokens = metadata.InputTokens ?? existing?.InputTokens, OutputTokens = metadata.OutputTokens ?? existing?.OutputTokens, - ResponseTokens = cumulative, - ContextPercent = metadata.ContextPercent ?? existing?.ContextPercent, + ResponseTokens = usageSnapshot, + ContextPercent = contextPercent, ContextTokens = contextTokens ?? existing?.ContextTokens, UsageContributionTokens = currentUsage, }; @@ -2259,26 +2293,6 @@ private bool SnapshotAssistantUsageContributionLocked( return false; } - private static int? LatestAssistantUsageBeforeLocked( - ChatTimelineState timeline, - IReadOnlyDictionary metadata, - int beforeIndex) - { - for (var i = beforeIndex - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.Assistant || - !metadata.TryGetValue(entry.Id, out var entryMetadata)) - { - continue; - } - var value = UsageValue(entryMetadata); - if (value is > 0) - return value; - } - return null; - } - private static int? UsageValue(ChatEntryMetadata metadata) => metadata.ResponseTokens ?? (metadata.InputTokens is { } input && diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs index d9329b408..1737fce66 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs @@ -95,6 +95,7 @@ Task ResolveAssistantMediaAsync( event EventHandler? StatusChanged; event EventHandler? SessionsUpdated; + event EventHandler? SessionUsageSnapshotUpdated; event EventHandler? SessionCommandCompleted; event EventHandler? ChatMessageReceived; event EventHandler? AgentEventReceived; @@ -109,6 +110,7 @@ public sealed class GatewayClientChatBridge : IChatGatewayBridge private readonly OpenClawGatewayClient _client; private readonly EventHandler _statusChangedHandler; private readonly EventHandler _sessionsUpdatedHandler; + private readonly EventHandler _sessionUsageSnapshotUpdatedHandler; private readonly EventHandler _sessionCommandCompletedHandler; private readonly EventHandler _chatMessageReceivedHandler; private readonly EventHandler _agentEventReceivedHandler; @@ -145,6 +147,8 @@ public GatewayClientChatBridge(OpenClawGatewayClient client) } }; _sessionsUpdatedHandler = (s, e) => SessionsUpdated?.Invoke(s, e); + _sessionUsageSnapshotUpdatedHandler = (s, e) => + SessionUsageSnapshotUpdated?.Invoke(s, e); _sessionCommandCompletedHandler = (s, e) => SessionCommandCompleted?.Invoke(s, e); _chatMessageReceivedHandler = (s, e) => ChatMessageReceived?.Invoke(s, e); _agentEventReceivedHandler = (s, e) => AgentEventReceived?.Invoke(s, e); @@ -169,6 +173,7 @@ public GatewayClientChatBridge(OpenClawGatewayClient client) // ``IsConnectedToGateway``. ``volatile`` covers atomic reads. _client.StatusChanged += _statusChangedHandler; _client.SessionsUpdated += _sessionsUpdatedHandler; + _client.SessionUsageSnapshotUpdated += _sessionUsageSnapshotUpdatedHandler; _client.SessionCommandCompleted += _sessionCommandCompletedHandler; _client.ChatMessageReceived += _chatMessageReceivedHandler; _client.AgentEventReceived += _agentEventReceivedHandler; @@ -266,6 +271,7 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision) => public event EventHandler? StatusChanged; public event EventHandler? SessionsUpdated; + public event EventHandler? SessionUsageSnapshotUpdated; public event EventHandler? SessionCommandCompleted; public event EventHandler? ChatMessageReceived; public event EventHandler? AgentEventReceived; @@ -278,6 +284,7 @@ public void Dispose() _client.StatusChanged -= _statusChangedHandler; _client.SessionsUpdated -= _sessionsUpdatedHandler; + _client.SessionUsageSnapshotUpdated -= _sessionUsageSnapshotUpdatedHandler; _client.SessionCommandCompleted -= _sessionCommandCompletedHandler; _client.ChatMessageReceived -= _chatMessageReceivedHandler; _client.AgentEventReceived -= _agentEventReceivedHandler; @@ -285,6 +292,7 @@ public void Dispose() StatusChanged = null; SessionsUpdated = null; + SessionUsageSnapshotUpdated = null; SessionCommandCompleted = null; ChatMessageReceived = null; AgentEventReceived = null; diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index aff1e0b63..7f6828ee0 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -170,6 +170,7 @@ internal OpenClawChatDataProvider( _bridge.StatusChanged += OnStatusChanged; _bridge.SessionsUpdated += OnSessionsUpdated; + _bridge.SessionUsageSnapshotUpdated += OnSessionUsageSnapshotUpdated; _bridge.SessionCommandCompleted += OnSessionCommandCompleted; _bridge.ChatMessageReceived += OnChatMessageReceived; _bridge.AgentEventReceived += OnAgentEventReceived; @@ -1073,6 +1074,7 @@ public ValueTask DisposeAsync() _persistence.Dispose(); _bridge.StatusChanged -= OnStatusChanged; _bridge.SessionsUpdated -= OnSessionsUpdated; + _bridge.SessionUsageSnapshotUpdated -= OnSessionUsageSnapshotUpdated; _bridge.SessionCommandCompleted -= OnSessionCommandCompleted; _bridge.ChatMessageReceived -= OnChatMessageReceived; _bridge.AgentEventReceived -= OnAgentEventReceived; @@ -1146,6 +1148,18 @@ private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) } } + private void OnSessionUsageSnapshotUpdated(object? sender, SessionInfo[] sessions) + { + if (_state.IsDisposed) + return; + if (_state.ApplyAuthoritativeSessionUsage( + sessions ?? [], + ProjectionContext()) is { } snapshot) + { + Publish(snapshot); + } + } + internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload( ChatEntryMetadata? metadata, int maxHistorySequence, diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index db00c8fff..1bf0d21aa 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -3408,6 +3408,28 @@ public void TrackedSessionActivity_SparseUpdatePreservesThinkingOverride() Assert.Equal("working", session.CurrentActivity); } + [Fact] + public void SessionUsageSnapshotUpdated_ExcludesTrackedActivityRepublishes() + { + var helper = new GatewayClientTestHelper(); + using var client = helper.Client; + var sessionUpdates = 0; + var usageSnapshots = 0; + client.SessionsUpdated += (_, _) => sessionUpdates++; + client.SessionUsageSnapshotUpdated += (_, _) => usageSnapshots++; + + helper.ParseSessionsPayload( + """[{"key":"agent:main:main","totalTokens":1000}]"""); + var update = typeof(OpenClawGatewayClient).GetMethod( + "UpdateTrackedSession", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)!; + update.Invoke(client, ["agent:main:main", true, "working"]); + + Assert.Equal(2, sessionUpdates); + Assert.Equal(1, usageSnapshots); + } + [Fact] public void ParseSessions_LegacyStatusOnlyEntryDoesNotPretendToClearThinkingMetadata() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 4d62ccfe2..68102be8e 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -233,6 +233,7 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision) public event EventHandler? StatusChanged; public event EventHandler? SessionsUpdated; + public event EventHandler? SessionUsageSnapshotUpdated; public event EventHandler? SessionCommandCompleted; public event EventHandler? ChatMessageReceived; public event EventHandler? AgentEventReceived; @@ -242,7 +243,13 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision) public EventHandler? CaptureStatusChangedHandlers() => StatusChanged; public void RaiseStatus(ConnectionStatus s) { CurrentStatus = s; StatusChanged?.Invoke(this, s); } - public void RaiseSessions(SessionInfo[] s) { Sessions = s; SessionsUpdated?.Invoke(this, s); } + public void RaiseSessions(SessionInfo[] s, bool hasAuthoritativeUsage = true) + { + Sessions = s; + SessionsUpdated?.Invoke(this, s); + if (hasAuthoritativeUsage) + SessionUsageSnapshotUpdated?.Invoke(this, s); + } public void RaiseSessionCommandCompleted(SessionCommandResult result) => SessionCommandCompleted?.Invoke(this, result); public void RaiseChat(ChatMessageInfo m) => ChatMessageReceived?.Invoke(this, m); public void RaiseAgent(AgentEventInfo a) => AgentEventReceived?.Invoke(this, a); @@ -11426,7 +11433,7 @@ public async Task ChatMessageReceived_AssistantFinal_MergesUsageMetadataOntoStre } [Fact] - public async Task ChatMessageReceived_AssistantFinal_AccumulatesUsageAcrossAssistantMessages() + public async Task ChatMessageReceived_AssistantFinal_UsesLatestRequestUsageAcrossAssistantMessages() { var session = new SessionInfo { @@ -11479,7 +11486,7 @@ public async Task ChatMessageReceived_AssistantFinal_AccumulatesUsageAcrossAssis var meta = provider.GetEntryMetadata("main"); Assert.Equal(27, meta[entries[0].Id].ResponseTokens); - Assert.Equal(52, meta[entries[1].Id].ResponseTokens); + Assert.Equal(25, meta[entries[1].Id].ResponseTokens); Assert.Equal(144_000, meta[entries[1].Id].ContextTokens); } @@ -11509,6 +11516,196 @@ public async Task ChatMessageReceived_DuplicateAssistantFinal_DoesNotDoubleAccum Assert.Equal(27, meta[entry.Id].ResponseTokens); } + [Fact] + public async Task ChatMessageReceived_DuplicateAssistantFinal_DoesNotUndoAuthoritativeSessionUsage() + { + var session = MainSession(); + session.TotalTokens = 1_000; + session.ContextTokens = 5_000; + var (bridge, provider, _, _) = CreateProvider(new[] { session }); + await provider.LoadAsync(); + + var message = new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "same", + State = "final", + Ts = 1714600005000, + InputTokens = 1_000, + OutputTokens = 500, + ResponseTokens = 1_500, + }; + bridge.RaiseChat(message); + + bridge.RaiseSessions( + [ + new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main session", + TotalTokens = 2_000, + ContextTokens = 5_000, + } + ]); + bridge.RaiseChat(message); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(2_000, meta[entry.Id].ResponseTokens); + Assert.Equal(1_500, meta[entry.Id].UsageContributionTokens); + } + + [Fact] + public async Task SessionUsageSnapshot_ReconcilesNewerAssistantContribution() + { + var session = MainSession(); + session.InputTokens = 1_500; + session.OutputTokens = 500; + session.TotalTokens = 2_000; + session.ContextTokens = 5_000; + var (bridge, provider, _, _) = CreateProvider(new[] { session }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "newer", + State = "final", + Ts = 1714600005000, + InputTokens = 1_000, + OutputTokens = 500, + ResponseTokens = 1_500, + }); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(1_500, meta[entry.Id].ResponseTokens); + Assert.Equal(5_000, meta[entry.Id].ContextTokens); + + bridge.RaiseSessions(new[] + { + new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main session", + InputTokens = 1_500, + OutputTokens = 500, + TotalTokens = 2_000, + ContextTokens = 5_000, + } + }); + + meta = provider.GetEntryMetadata("main"); + Assert.Equal(2_000, meta[entry.Id].ResponseTokens); + } + + [Fact] + public async Task SessionsUpdated_ActivityOnlyCachedUsageDoesNotReplaceContributionOrCorrection() + { + var session = MainSession(); + session.TotalTokens = 1_000; + session.ContextTokens = 5_000; + var (bridge, provider, _, _) = CreateProvider(new[] { session }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "newer", + State = "final", + Ts = 1714600005000, + InputTokens = 1_000, + OutputTokens = 500, + ResponseTokens = 1_500, + }); + + var cached = new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main session", + TotalTokens = 1_000, + ContextTokens = 5_000, + CurrentActivity = "Running tool", + }; + bridge.RaiseSessions([cached], hasAuthoritativeUsage: false); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(1_500, meta[entry.Id].ResponseTokens); + Assert.Equal(1_500, meta[entry.Id].UsageContributionTokens); + + var authoritative = new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main session", + TotalTokens = 2_000, + ContextTokens = 5_000, + }; + bridge.RaiseSessions([authoritative]); + authoritative.CurrentActivity = "Running another tool"; + bridge.RaiseSessions([authoritative], hasAuthoritativeUsage: false); + + meta = provider.GetEntryMetadata("main"); + Assert.Equal(2_000, meta[entry.Id].ResponseTokens); + Assert.Equal(1_500, meta[entry.Id].UsageContributionTokens); + } + + [Fact] + public async Task SessionsUpdated_ClearsStalePercentWhenAuthoritativeUsageMatchesContribution() + { + var session = MainSession(); + session.InputTokens = 1_500; + session.OutputTokens = 500; + session.TotalTokens = 2_000; + session.ContextTokens = 5_000; + var (bridge, provider, _, _) = CreateProvider(new[] { session }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "final", + State = "final", + Ts = 1714600005000, + InputTokens = 1_500, + OutputTokens = 500, + ResponseTokens = 2_000, + ContextPercent = 100, + }); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal("5.0K/5.0K (100%)", ChatUsageFormatter.Format(meta[entry.Id])); + + bridge.RaiseSessions(new[] + { + new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main session", + InputTokens = 1_500, + OutputTokens = 500, + TotalTokens = 2_000, + ContextTokens = 5_000, + } + }); + + meta = provider.GetEntryMetadata("main"); + Assert.Equal(2_000, meta[entry.Id].ResponseTokens); + Assert.Null(meta[entry.Id].ContextPercent); + Assert.Equal(2_000, meta[entry.Id].UsageContributionTokens); + Assert.Equal("2.0K/5.0K (40%)", ChatUsageFormatter.Format(meta[entry.Id])); + } + [Fact] public async Task SessionsUpdated_SnapshotsThreadUsageOntoLatestAssistantWithoutOwnUsage() { @@ -11585,7 +11782,7 @@ public async Task SessionsUpdated_OverwritesLatestAssistantResponseUsageWithCumu } [Fact] - public async Task SessionsUpdated_DoesNotLowerExistingAssistantUsageSnapshot() + public async Task SessionsUpdated_CanLowerExistingAssistantUsageSnapshot() { var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); await provider.LoadAsync(); @@ -11600,6 +11797,7 @@ public async Task SessionsUpdated_DoesNotLowerExistingAssistantUsageSnapshot() InputTokens = 5_000, OutputTokens = 1_900, ResponseTokens = 6_900, + ContextPercent = 100, }); var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); @@ -11618,8 +11816,10 @@ public async Task SessionsUpdated_DoesNotLowerExistingAssistantUsageSnapshot() }); var meta = provider.GetEntryMetadata("main"); - Assert.Equal(6_900, meta[entry.Id].ResponseTokens); + Assert.Equal(4_100, meta[entry.Id].ResponseTokens); Assert.Equal(400_000, meta[entry.Id].ContextTokens); + Assert.Null(meta[entry.Id].ContextPercent); + Assert.Equal("4.1K/400.0K (1%)", ChatUsageFormatter.Format(meta[entry.Id])); bridge.RaiseSessions(new[] { @@ -11635,7 +11835,7 @@ public async Task SessionsUpdated_DoesNotLowerExistingAssistantUsageSnapshot() }); meta = provider.GetEntryMetadata("main"); - Assert.Equal(6_900, meta[entry.Id].ResponseTokens); + Assert.Equal(4_100, meta[entry.Id].ResponseTokens); Assert.Equal(400_000, meta[entry.Id].ContextTokens); } diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs index 645ce9710..606a0d318 100644 --- a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs +++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs @@ -769,6 +769,11 @@ public Task SendChatMessageForRunAsync( public Task ResolveExecApprovalAsync(string approvalId, string decision) => Task.CompletedTask; public event EventHandler? StatusChanged; public event EventHandler? SessionsUpdated; + public event EventHandler? SessionUsageSnapshotUpdated + { + add { } + remove { } + } public event EventHandler? SessionCommandCompleted; public event EventHandler? ChatMessageReceived; public event EventHandler? AgentEventReceived;