Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ protected override void OnError(Exception ex)
public event EventHandler<AgentActivity>? ActivityChanged;
public event EventHandler<ChannelHealth[]>? ChannelHealthUpdated;
public event EventHandler<SessionInfo[]>? SessionsUpdated;
/// <summary>
/// Raised only for fresh <c>sessions.list</c> responses. Unlike
/// <see cref="SessionsUpdated"/>, this event is not raised when a tool or
/// job activity update republishes the cached session collection.
/// </summary>
public event EventHandler<SessionInfo[]>? SessionUsageSnapshotUpdated;
public event EventHandler<GatewayUsageInfo>? UsageUpdated;
public event EventHandler<GatewayUsageStatusInfo>? UsageStatusUpdated;
public event EventHandler<GatewayCostUsageInfo>? UsageCostUpdated;
Expand Down Expand Up @@ -4401,6 +4407,7 @@ private void ParseSessions(JsonElement sessions)
}

SessionsUpdated?.Invoke(this, snapshot);
SessionUsageSnapshotUpdated?.Invoke(this, snapshot);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ public event EventHandler<SessionInfo[]>? SessionsUpdated
add { }
remove { }
}
public event EventHandler<SessionInfo[]>? SessionUsageSnapshotUpdated
{
add { }
remove { }
}

public event EventHandler<SessionCommandResult>?
SessionCommandCompleted
Expand Down
126 changes: 70 additions & 56 deletions src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
{
Expand All @@ -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,
};
Expand All @@ -2259,26 +2293,6 @@ private bool SnapshotAssistantUsageContributionLocked(
return false;
}

private static int? LatestAssistantUsageBeforeLocked(
ChatTimelineState timeline,
IReadOnlyDictionary<string, ChatEntryMetadata> 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 &&
Expand Down
8 changes: 8 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Task<AssistantMediaResolutionResult> ResolveAssistantMediaAsync(

event EventHandler<ConnectionStatus>? StatusChanged;
event EventHandler<SessionInfo[]>? SessionsUpdated;
event EventHandler<SessionInfo[]>? SessionUsageSnapshotUpdated;
event EventHandler<SessionCommandResult>? SessionCommandCompleted;
event EventHandler<ChatMessageInfo>? ChatMessageReceived;
event EventHandler<AgentEventInfo>? AgentEventReceived;
Expand All @@ -109,6 +110,7 @@ public sealed class GatewayClientChatBridge : IChatGatewayBridge
private readonly OpenClawGatewayClient _client;
private readonly EventHandler<ConnectionStatus> _statusChangedHandler;
private readonly EventHandler<SessionInfo[]> _sessionsUpdatedHandler;
private readonly EventHandler<SessionInfo[]> _sessionUsageSnapshotUpdatedHandler;
private readonly EventHandler<SessionCommandResult> _sessionCommandCompletedHandler;
private readonly EventHandler<ChatMessageInfo> _chatMessageReceivedHandler;
private readonly EventHandler<AgentEventInfo> _agentEventReceivedHandler;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -266,6 +271,7 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision) =>

public event EventHandler<ConnectionStatus>? StatusChanged;
public event EventHandler<SessionInfo[]>? SessionsUpdated;
public event EventHandler<SessionInfo[]>? SessionUsageSnapshotUpdated;
public event EventHandler<SessionCommandResult>? SessionCommandCompleted;
public event EventHandler<ChatMessageInfo>? ChatMessageReceived;
public event EventHandler<AgentEventInfo>? AgentEventReceived;
Expand All @@ -278,13 +284,15 @@ public void Dispose()

_client.StatusChanged -= _statusChangedHandler;
_client.SessionsUpdated -= _sessionsUpdatedHandler;
_client.SessionUsageSnapshotUpdated -= _sessionUsageSnapshotUpdatedHandler;
_client.SessionCommandCompleted -= _sessionCommandCompletedHandler;
_client.ChatMessageReceived -= _chatMessageReceivedHandler;
_client.AgentEventReceived -= _agentEventReceivedHandler;
_client.ModelsListUpdated -= _modelsListUpdatedHandler;

StatusChanged = null;
SessionsUpdated = null;
SessionUsageSnapshotUpdated = null;
SessionCommandCompleted = null;
ChatMessageReceived = null;
AgentEventReceived = null;
Expand Down
14 changes: 14 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ internal OpenClawChatDataProvider(

_bridge.StatusChanged += OnStatusChanged;
_bridge.SessionsUpdated += OnSessionsUpdated;
_bridge.SessionUsageSnapshotUpdated += OnSessionUsageSnapshotUpdated;
_bridge.SessionCommandCompleted += OnSessionCommandCompleted;
_bridge.ChatMessageReceived += OnChatMessageReceived;
_bridge.AgentEventReceived += OnAgentEventReceived;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading