From 04e7c2255398fefe6192f8f6077a2067c1602bee Mon Sep 17 00:00:00 2001 From: zenpasdan1 Date: Sat, 10 Oct 2026 00:46:00 +0300 Subject: [PATCH 1/5] feat(chat): show the estimated cost of each assistant reply in the message footer Read usage.cost.total from assistant message frames and chat.history rows into ChatMessageInfo.CostUsd, carry it on ChatEntryMetadata, and render it after the usage summary in the assistant footer with the gateway Control UI precision. The value is attached to the latest assistant entry through a dedicated ChatConversationState.SnapshotAssistantCost path so the usage snapshot logic that #1473 reworks is left untouched. Cost is hidden when the gateway reports none. Related: #1433, #1473, #551 Co-Authored-By: Claude Fable 5.1 --- docs/CHAT_VISUAL_DESIGN.md | 12 + docs/TEST_COVERAGE.md | 4 +- src/OpenClaw.Shared/Models.cs | 7 + src/OpenClaw.Shared/OpenClawGatewayClient.cs | 22 +- .../Chat/ChatConversationState.cs | 34 ++ .../Chat/ChatEntryMetadata.cs | 8 +- .../Chat/ChatHistoryLoader.cs | 1 + .../Chat/ChatUsageFormatter.cs | 17 + .../Chat/OpenClawChatDataProvider.cs | 6 + .../Chat/ReactorChatTimeline.cs | 3 +- .../ChatMessageCostTests.cs | 23 ++ .../OpenClawGatewayClientTests.cs | 87 ++++ .../ChatCostFooterContractTests.cs | 20 + .../ChatUsageFormatterTests.cs | 31 ++ .../OpenClawChatDataProviderTests.cs | 376 ++++++++++++++++++ 15 files changed, 644 insertions(+), 7 deletions(-) create mode 100644 tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs diff --git a/docs/CHAT_VISUAL_DESIGN.md b/docs/CHAT_VISUAL_DESIGN.md index 6df7fc356..84d85feb2 100644 --- a/docs/CHAT_VISUAL_DESIGN.md +++ b/docs/CHAT_VISUAL_DESIGN.md @@ -91,6 +91,18 @@ action visible without hovering. Provider promotion replaces the pending row wit without adding queue-only rows to history. This applies to both ChatPage and ChatWindow. See the [pending-message concept](design/reference/concepts/states/pending-chat-message.md). +Assistant messages carry a quiet metadata footer under the bubble: local time, +model name, the latest context usage (shown on the newest assistant message only +while tool calls are visible), and the per-message cost when the gateway reports +`usage.cost.total` for that message. Cost renders as `$0.225` with the gateway +Control UI precision (four decimals below $0.01, three below $1, otherwise two); +amounts below $0.0001 render as `<$0.0001`. The amount is the gateway's estimate +from its configured per-token prices, not an invoice: for a local model it is a +counter at whatever prices were configured, and subscription or OAuth providers +may show a value that is never billed. Missing, zero, negative, or non-finite +costs are hidden. `ChatUsageFormatter` owns usage and cost text; the timeline +only joins the parts with a middle dot. + Chat responses are not read aloud by default. Enabling the Text-to-speech capability permits playback but does not enable automatic spoken replies. Settings > Voice > "Read responses aloud" opts in to automatic playback; diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index cc158e343..3a5f12adc 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -51,7 +51,7 @@ per-run artifacts. Existing MCP-only integration defaults remain unchanged. ### OpenClaw.Shared.Tests -- **Model and display formatting** - activity glyphs, app version display, session labels, gateway usage/node display, channel status, and rich text helpers. +- **Model and display formatting** - activity glyphs, app version display, session labels, gateway usage/node display, chat message cost parsing, channel status, and rich text helpers. - **Gateway and WebSocket behavior** - gateway client parsing, session keys, WebSocket base handling, URL normalization, local gateway classification, and token sanitization. - **Capabilities and MCP** - app/canvas/screen/camera/system capabilities, MCP auth token reset, MCP HTTP server, MCP tool bridge, MXC availability, MXC policy building, and command runners. - **Exec approval** - legacy policy coverage plus V2 evaluator, input validation, normalization, prompt adapter, routing, coordinator, store, environment sanitizing, and shell-wrapper parsing. @@ -60,7 +60,7 @@ per-run artifacts. Existing MCP-only integration defaults remain unchanged. ### OpenClaw.Tray.Tests -- **Tray UI and state** - app state, menu display/position/sizing, tray tooltip formatting, activity streams, async list loading, diagnostics contracts, markup regressions, and chat timeline/markdown handling. +- **Tray UI and state** - app state, menu display/position/sizing, tray tooltip formatting, activity streams, async list loading, diagnostics contracts, markup regressions, chat timeline/markdown handling, and assistant footer usage/cost formatting. - **Connection and pairing** - connection manager node connector tests, connection page approval/channel metrics/row state, operator and Windows tray node pairing approval, and gateway action transport. - **Settings and startup** - settings round-trip/isolation, consent and settings save, auto-start defaults, startup setup state, existing config guard policy, and local setup progress stage mapping. - **Onboarding and local gateway setup** - onboarding completion/chat bootstrapper/existing config guard, wizard flow/selection/error/step parsing, setup code decoding, local gateway setup diagnostics, uninstall, WSL keep-alive, and auto-pair flags. diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index c683d2ca1..852b21e11 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1841,6 +1841,13 @@ public static bool IsSilentAssistantDirective(string? role, string? text) => /// Unix epoch milliseconds when the gateway logged this message (0 if unknown). public long Ts { get; set; } + /// + /// Gateway-reported cost in USD from usage.cost.total on the + /// assistant message, or null when absent or not a finite + /// non-negative number. + /// + public double? CostUsd { get; set; } + /// /// Optional cumulative input (prompt) token count for the turn this /// message belongs to, when the gateway includes a usage block on diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 308b5f951..8b9194567 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -792,6 +792,7 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess CompactionTokensBefore = openClawMetadata.TokensBefore, CompactionTokensAfter = openClawMetadata.TokensAfter, StopReason = stopReason, + CostUsd = ExtractChatCost(m), InputTokens = inputTokens, OutputTokens = outputTokens, ResponseTokens = responseTokens, @@ -802,6 +803,17 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess return info; } + private static double? ExtractChatCost(JsonElement message) + { + if (message.ValueKind == JsonValueKind.Object && + message.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object && + usage.TryGetProperty("cost", out var cost) && cost.ValueKind == JsonValueKind.Object && + cost.TryGetProperty("total", out var total) && total.ValueKind == JsonValueKind.Number && total.TryGetDouble(out var value) && + double.IsFinite(value) && value >= 0) + return value; + return null; + } + private static string ExtractMessageText(JsonElement message, string role) { if (!message.TryGetProperty("content", out var content)) return string.Empty; @@ -4079,7 +4091,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) messageOpenClawMetadata.Kind ?? payloadOpenClawMetadata.Kind, messageOpenClawMetadata.TokensBefore ?? payloadOpenClawMetadata.TokensBefore, messageOpenClawMetadata.TokensAfter ?? payloadOpenClawMetadata.TokensAfter, - contentParts); + contentParts, + costUsd: ExtractChatCost(message) ?? ExtractChatCost(payload)); if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase)) { @@ -4120,7 +4133,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) openClawMetadata.Kind, openClawMetadata.TokensBefore, openClawMetadata.TokensAfter, - projection.ContentParts); + projection.ContentParts, + costUsd: ExtractChatCost(payload)); if (role == "assistant" && (string.IsNullOrWhiteSpace(state) || @@ -4200,7 +4214,8 @@ private void EmitChatMessageReceived( string? openClawKind = null, long? compactionTokensBefore = null, long? compactionTokensAfter = null, - IReadOnlyList? contentParts = null) + IReadOnlyList? contentParts = null, + double? costUsd = null) { if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return; @@ -4215,6 +4230,7 @@ private void EmitChatMessageReceived( ContentParts = contentParts ?? Array.Empty(), State = state, Ts = tsMs, + CostUsd = costUsd, InputTokens = inputTokens, OutputTokens = outputTokens, ResponseTokens = responseTokens, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs index 494d8c86c..7da4ec239 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs @@ -2211,6 +2211,40 @@ private static bool TryGetTerminalAgentRunId( } } + /// + /// Attaches a gateway-reported cost to the latest assistant entry of the + /// thread. Returns a snapshot only when the stored value changed; the + /// latest rendered frame wins (identified duplicates never reach here + /// because the queue classifier drops them). + /// + internal ChatDataSnapshot? SnapshotAssistantCost( + string threadId, + double costUsd, + ChatProjectionContext context) + { + lock (_gate) + { + if (!_timelines.TryGetValue(threadId, out var timeline)) + return null; + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant) + continue; + var threadMetadata = GetOrCreateThreadMetaLocked(threadId); + threadMetadata.TryGetValue(entry.Id, out var existing); + if (existing?.CostUsd is { } current && current.Equals(costUsd)) + return null; + threadMetadata[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with + { + CostUsd = costUsd, + }; + return BuildSnapshotLocked(context); + } + return null; + } + } + internal ChatDataSnapshot? SnapshotAssistantUsageContribution( string threadId, ChatEntryMetadata metadata, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index eeabe0fcb..dea1ce044 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -7,7 +7,7 @@ namespace OpenClawTray.Chat; /// carry — specifically the wall-clock timestamp of when the entry was /// created, the model active at that moment, and gateway-reported usage /// counters — so the timeline renderer can show a richer footer like -/// Field · 7:54 PM · ↑1475 ↓12 R45.4k 23% ctx · gpt-5.5. +/// 7:54 PM · gpt-5.5 · 45.4K/200.0K (23%) · $0.225. /// /// /// Local-time timestamp of when the entry was created. null when the @@ -36,6 +36,11 @@ namespace OpenClawTray.Chat; /// Percentage of the model's context window consumed by the conversation /// when this entry was generated (0–100). Shown as 23% ctx. /// +/// +/// Gateway-reported cost in USD for this assistant entry +/// (usage.cost.total), as carried on the message frame or history +/// row. null when the gateway reported none. +/// /// /// Total context window size captured with a session usage snapshot. Used by /// timestamp usage placement to render usage/total (%) exactly. @@ -75,6 +80,7 @@ public sealed record ChatEntryMetadata( int? OutputTokens = null, int? ResponseTokens = null, int? ContextPercent = null, + double? CostUsd = null, long? ContextTokens = null, int? UsageContributionTokens = null, string? GatewayMessageId = null, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs index b38b36f40..1d40e1b43 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs @@ -603,6 +603,7 @@ string AllocateSyntheticToolCallId() message.OutputTokens, message.ResponseTokens, message.ContextPercent, + CostUsd: message.CostUsd, GatewayMessageId: message.OpenClawId, OpenClawSeq: message.OpenClawSeq, OpenClawKind: message.OpenClawKind, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs index dbdb7cced..4725d9871 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs @@ -88,6 +88,23 @@ public static class ChatUsageFormatter : null; } + /// + /// Formats the gateway-reported per-message cost for the assistant footer + /// using the same precision as the gateway's Control UI (4 decimals below + /// $0.01, 3 below $1, otherwise 2), e.g. $0.588. Values that would + /// round to zero render as <$0.0001. Missing, zero, negative, + /// or non-finite values are hidden. + /// + public static string? FormatCost(double? costUsd) + { + if (costUsd is not { } cost || !double.IsFinite(cost) || cost <= 0) + return null; + if (cost < 0.00005) + return "<$0.0001"; + var decimals = cost < 0.01 ? 4 : cost < 1 ? 3 : 2; + return "$" + cost.ToString("F" + decimals, CultureInfo.InvariantCulture); + } + private static string FormatCount(long value) { if (value >= 1_000_000) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 9f5485868..cda0d26a9 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -1432,6 +1432,12 @@ message.ResponseTokens is not null || Publish(usageSnapshot); } + if (message.CostUsd is { } costUsd && + _state.SnapshotAssistantCost(threadId, costUsd, ProjectionContext()) is { } costSnapshot) + { + Publish(costSnapshot); + } + if (!message.IsFinal) return; var completedRunId = _state.CompleteAssistantFinal(threadId); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs index 7cffcc486..5b0fe607c 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs @@ -1117,9 +1117,10 @@ private static string FooterText( && row.Props.Timeline.ShowToolCalls ? row.Props.Timeline.DefaultUsageSummary : null; + var cost = ChatUsageFormatter.FormatCost(metadata?.CostUsd); return string.Join( " · ", - new[] { time, model, usageSummary }.Where(static value => !string.IsNullOrWhiteSpace(value))); + new[] { time, model, usageSummary, cost }.Where(static value => !string.IsNullOrWhiteSpace(value))); } private static Element UserMetadata( diff --git a/tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs b/tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs new file mode 100644 index 000000000..efae10729 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Text.Json; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class ChatMessageCostTests +{ + [Theory] + [InlineData("{\"usage\":{\"cost\":{\"total\":0.22503}}}", 0.22503)] + [InlineData("{\"usage\":{\"cost\":{\"total\":12}}}", 12.0)] + [InlineData("{\"usage\":{\"cost\":{\"total\":0}}}", 0.0)] + [InlineData("{}", null)] + [InlineData("{\"usage\":{\"cost\":0.5}}", null)] + [InlineData("{\"usage\":{\"cost\":{\"total\":-1}}}", null)] + [InlineData("{\"usage\":{\"cost\":{\"total\":\"bad\"}}}", null)] + public void ReadsReportedCostAndRejectsInvalidValues(string json, double? expected) + { + using var document = JsonDocument.Parse(json); + var method = typeof(OpenClawGatewayClient).GetMethod("ExtractChatCost", BindingFlags.NonPublic | BindingFlags.Static)!; + Assert.Equal(expected, (double?)method.Invoke(null, new object[] { document.RootElement })); + } +} diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index ccb15fdb3..4fa32b6b8 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -2043,6 +2043,93 @@ public void ProcessRawMessage_SessionMessageWithContentBlocks_EmitsChatMessage() Assert.Equal(1781631280633, received.Ts); } + [Fact] + public void ChatEvent_ReadsAssistantMessageUsageCost() + { + var helper = new GatewayClientTestHelper(); + ChatMessageInfo? received = null; + helper.Client.ChatMessageReceived += (_, message) => received = message; + + helper.ProcessRawMessage(""" + { + "type": "event", + "event": "session.message", + "payload": { + "sessionKey": "main", + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "priced reply" } + ], + "timestamp": 1781631280633, + "usage": { "cost": { "total": 0.22503 } } + }, + "state": "final" + } + } + """); + + Assert.NotNull(received); + Assert.Equal(0.22503, received!.CostUsd); + } + + [Fact] + public void ChatEvent_PrefersMessageUsageCostOverPayloadCost() + { + var helper = new GatewayClientTestHelper(); + ChatMessageInfo? received = null; + helper.Client.ChatMessageReceived += (_, message) => received = message; + + helper.ProcessRawMessage(""" + { + "type": "event", + "event": "session.message", + "payload": { + "sessionKey": "main", + "usage": { "cost": { "total": 0.9 } }, + "message": { + "role": "assistant", + "content": "priced reply", + "timestamp": 1781631280633, + "usage": { "cost": { "total": 0.2 } } + }, + "state": "final" + } + } + """); + + Assert.NotNull(received); + Assert.Equal(0.2, received!.CostUsd); + } + + [Fact] + public void ChatEvent_FallsBackToPayloadUsageCost_WhenMessageHasNone() + { + var helper = new GatewayClientTestHelper(); + ChatMessageInfo? received = null; + helper.Client.ChatMessageReceived += (_, message) => received = message; + + helper.ProcessRawMessage(""" + { + "type": "event", + "event": "session.message", + "payload": { + "sessionKey": "main", + "usage": { "cost": { "total": 0.9 } }, + "message": { + "role": "assistant", + "content": "priced reply", + "timestamp": 1781631280633 + }, + "state": "final" + } + } + """); + + Assert.NotNull(received); + Assert.Equal(0.9, received!.CostUsd); + } + [Fact] public void ProcessRawMessage_SessionMessageAssistantNoReply_DropsFrame() { diff --git a/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs new file mode 100644 index 000000000..88bb13be9 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs @@ -0,0 +1,20 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class ChatCostFooterContractTests +{ + [Fact] + public void AssistantFooter_RendersCostThroughSharedFormatter() + { + var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ReactorChatTimeline.cs"); + var footer = timeline[timeline.IndexOf("private static string FooterText(", StringComparison.Ordinal).. + timeline.IndexOf("private static Element UserMetadata(", StringComparison.Ordinal)]; + + // The footer gets its cost text from the shared formatter so the + // precision rule is decided in one tested place. + Assert.Contains("ChatUsageFormatter.FormatCost(", footer); + Assert.Contains("metadata?.CostUsd", footer); + } + + private static string Read(params string[] parts) + => File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(parts).ToArray())); +} diff --git a/tests/OpenClaw.Tray.Tests/ChatUsageFormatterTests.cs b/tests/OpenClaw.Tray.Tests/ChatUsageFormatterTests.cs index 452363eff..9ec6054a0 100644 --- a/tests/OpenClaw.Tray.Tests/ChatUsageFormatterTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatUsageFormatterTests.cs @@ -5,6 +5,37 @@ namespace OpenClaw.Tray.Tests; public class ChatUsageFormatterTests { + [Theory] + [InlineData(0.00006, "$0.0001")] + [InlineData(0.0005, "$0.0005")] + [InlineData(0.009, "$0.0090")] + [InlineData(0.22503, "$0.225")] + [InlineData(0.5876, "$0.588")] + [InlineData(1.0, "$1.00")] + [InlineData(12.345, "$12.35")] + [InlineData(1234.5, "$1234.50")] + public void FormatCost_MatchesControlUiPrecision(double cost, string expected) + { + Assert.Equal(expected, ChatUsageFormatter.FormatCost(cost)); + } + + [Fact] + public void FormatCost_ShowsFloorForCostBelowDisplayPrecision() + { + Assert.Equal("<$0.0001", ChatUsageFormatter.FormatCost(0.00004)); + } + + [Theory] + [InlineData(null)] + [InlineData(0.0)] + [InlineData(-0.5)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void FormatCost_HidesMissingZeroOrInvalidCost(double? cost) + { + Assert.Null(ChatUsageFormatter.FormatCost(cost)); + } + [Fact] public void Format_UsesThreadTotals() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index dd38d0cb4..fd5b80d55 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -14753,6 +14753,382 @@ public void LoadLastChatState_WithCorruptedJson_ReturnsNull() Assert.Null(state); } + [Fact] + public async Task ChatMessageReceived_AssistantFinal_MergesCostOntoStreamingEntry() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "partial", + State = "delta", + Ts = 1714600005000 + }); + var entryId = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries).Id; + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "final", + State = "final", + Ts = 1714600006000, + InputTokens = 20478, + OutputTokens = 405, + ResponseTokens = 20883, + CostUsd = 0.22503, + }); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + Assert.Equal(entryId, entry.Id); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(0.22503, meta[entry.Id].CostUsd); + Assert.Equal(20883, meta[entry.Id].ResponseTokens); + } + + [Fact] + public async Task ChatMessageReceived_CostOnlyFinal_AttachesCostToLatestAssistantEntry() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "done", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + }); + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + Assert.Null(provider.GetEntryMetadata("main")[entry.Id].CostUsd); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "done", + State = "final", + Ts = 1714600005100, + CostUsd = 0.0005, + }); + + var refined = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + Assert.Equal(entry.Id, refined.Id); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(0.0005, meta[entry.Id].CostUsd); + Assert.Equal(27, meta[entry.Id].ResponseTokens); + Assert.Equal(20, meta[entry.Id].InputTokens); + } + + [Fact] + public async Task ChatMessageReceived_DuplicateAssistantFinalWithCost_KeepsSingleCostAndUsage() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + var message = new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "same", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + CostUsd = 0.22503, + }; + + bridge.RaiseChat(message); + bridge.RaiseChat(message); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(0.22503, meta[entry.Id].CostUsd); + Assert.Equal(27, meta[entry.Id].ResponseTokens); + } + + [Fact] + public async Task ChatMessageReceived_CostStaysOnItsOwnAssistantMessage() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + CostUsd = 0.1, + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "again", + State = "final", + Ts = 1714600005500, + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "second", + State = "final", + Ts = 1714600006000, + InputTokens = 20, + OutputTokens = 5, + ResponseTokens = 25, + }); + + var entries = (await provider.LoadAsync()).Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToArray(); + Assert.Equal(2, entries.Length); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(0.1, meta[entries[0].Id].CostUsd); + Assert.Null(meta[entries[1].Id].CostUsd); + } + + [Fact] + public async Task ChatMessageReceived_CostAfterToolCall_LandsOnFinalAssistantEntry() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "let me check", + State = "final", + Ts = 1714600005000, + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "toolresult", + Text = "Process exited with code 0", + State = "final", + Ts = 1714600005500, + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "all good", + State = "final", + Ts = 1714600006000, + InputTokens = 40, + OutputTokens = 9, + ResponseTokens = 49, + CostUsd = 0.012, + }); + + var entries = (await provider.LoadAsync()).Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToArray(); + Assert.Equal(2, entries.Length); + var meta = provider.GetEntryMetadata("main"); + Assert.Null(meta[entries[0].Id].CostUsd); + Assert.Equal(0.012, meta[entries[1].Id].CostUsd); + } + + [Theory] + [InlineData(0.5, 0.0, null, null, 0.0)] + [InlineData(0.5, 0.3, null, null, 0.3)] + [InlineData(0.5, 0.3, "m1", 1, 0.5)] + public async Task ChatMessageReceived_LaterAssistantFinalCost_ReplacesEarlierCost( + double first, + double second, + string? id, + int? seq, + double expected) + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "same", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + CostUsd = first, + OpenClawId = id, + OpenClawSeq = seq, + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "same", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + CostUsd = second, + OpenClawId = id, + OpenClawSeq = seq, + }); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(expected, meta[entry.Id].CostUsd); + Assert.Equal(27, meta[entry.Id].ResponseTokens); + } + + [Fact] + public async Task SessionsUpdated_PreservesAssistantCost() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "final", + State = "final", + Ts = 1714600005000, + InputTokens = 1_000, + OutputTokens = 500, + ResponseTokens = 1_500, + CostUsd = 0.3, + }); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + + bridge.RaiseSessions(new[] + { + new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main session", + InputTokens = 20_000, + OutputTokens = 3_700, + TotalTokens = 23_500, + ContextTokens = 400_000, + } + }); + + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(0.3, meta[entry.Id].CostUsd); + Assert.Equal(23_500, meta[entry.Id].ResponseTokens); + Assert.Equal(400_000, meta[entry.Id].ContextTokens); + } + + [Fact] + public async Task LoadHistoryAsync_CapturesAssistantCost() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + Role = "assistant", + Text = "old", + State = "final", + Ts = 1714600001000, + InputTokens = 10, + OutputTokens = 20, + ResponseTokens = 30, + CostUsd = 0, + }, + new ChatMessageInfo + { + Role = "assistant", + Text = "priced", + State = "final", + Ts = 1714600002000, + InputTokens = 20478, + OutputTokens = 405, + ResponseTokens = 20883, + CostUsd = 0.22503, + }, + } + }); + await provider.LoadAsync(); + + await provider.LoadHistoryAsync("main"); + + var entries = (await provider.LoadAsync()).Timelines["main"].Entries; + Assert.Equal(2, entries.Count); + var meta = provider.GetEntryMetadata("main"); + Assert.Equal(0, meta[entries[0].Id].CostUsd); + Assert.Equal(0.22503, meta[entries[1].Id].CostUsd); + } + + [Fact] + public async Task LoadHistoryAsync_HistoryRowWithoutCost_ReplacesLiveCost() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "priced", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + CostUsd = 0.3, + OpenClawId = "msg-priced", + OpenClawSeq = 7, + }); + var live = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + Assert.Equal(0.3, provider.GetEntryMetadata("main")[live.Id].CostUsd); + + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + Role = "assistant", + Text = "priced", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + OpenClawId = "msg-priced", + OpenClawSeq = 7, + }, + } + }); + + await provider.LoadHistoryAsync("main"); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + Assert.Null(provider.GetEntryMetadata("main")[entry.Id].CostUsd); + } + private static IReadOnlyList GetQueuedMessages(ChatDataSnapshot snapshot, string threadId) => snapshot.QueuedMessagesByThread is not null && snapshot.QueuedMessagesByThread.TryGetValue(threadId, out var queued) From b00a02b987fdc1860d216663ff952c5e1be793f6 Mon Sep 17 00:00:00 2001 From: zenpasdan1 Date: Sat, 10 Oct 2026 00:52:50 +0300 Subject: [PATCH 2/5] fix(chat): address review findings for cost footer Pin the chat.history cost ingress through the real parser (replacing the reflection-only ExtractChatCost test), add a legacy payload.text cost test, skip the redundant snapshot when a fresh entry reports a zero cost, make the duplicate-final test count publishes against an uncosted baseline, guard the footer contract test anchors, cite the upstream Control UI formatCost source, and correct the docs: the <$0.0001 floor applies below $0.00005 and the whole assistant footer stays gated by the tool-call visibility setting from #551. Co-Authored-By: Claude Fable 5.1 --- docs/CHAT_VISUAL_DESIGN.md | 12 +-- .../Chat/ChatConversationState.cs | 4 + .../Chat/ChatEntryMetadata.cs | 15 ++-- .../Chat/ChatUsageFormatter.cs | 10 ++- .../ChatMessageCostTests.cs | 23 ------ .../OpenClawGatewayClientTests.cs | 78 +++++++++++++++++++ .../ChatCostFooterContractTests.cs | 6 +- .../OpenClawChatDataProviderTests.cs | 46 ++++++++++- 8 files changed, 151 insertions(+), 43 deletions(-) delete mode 100644 tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs diff --git a/docs/CHAT_VISUAL_DESIGN.md b/docs/CHAT_VISUAL_DESIGN.md index 84d85feb2..03e772542 100644 --- a/docs/CHAT_VISUAL_DESIGN.md +++ b/docs/CHAT_VISUAL_DESIGN.md @@ -92,11 +92,13 @@ without adding queue-only rows to history. This applies to both ChatPage and ChatWindow. See the [pending-message concept](design/reference/concepts/states/pending-chat-message.md). Assistant messages carry a quiet metadata footer under the bubble: local time, -model name, the latest context usage (shown on the newest assistant message only -while tool calls are visible), and the per-message cost when the gateway reports -`usage.cost.total` for that message. Cost renders as `$0.225` with the gateway -Control UI precision (four decimals below $0.01, three below $1, otherwise two); -amounts below $0.0001 render as `<$0.0001`. The amount is the gateway's estimate +model name, the latest context usage (on the newest assistant message only), and +the per-message cost when the gateway reports `usage.cost.total` for that +message. The whole footer renders on the last bubble of each assistant run and +only while tool calls are visible (Settings > Show tool calls). Cost renders as +`$0.225` with the gateway Control UI precision (four decimals below $0.01, three +below $1, otherwise two); amounts that would round to zero at four decimals +(below $0.00005) render as `<$0.0001`. The amount is the gateway's estimate from its configured per-token prices, not an invoice: for a local model it is a counter at whatever prices were configured, and subscription or OAuth providers may show a value that is never billed. Missing, zero, negative, or non-finite diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs index 7da4ec239..3a2285325 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs @@ -2235,6 +2235,10 @@ private static bool TryGetTerminalAgentRunId( threadMetadata.TryGetValue(entry.Id, out var existing); if (existing?.CostUsd is { } current && current.Equals(costUsd)) return null; + // A reported zero on an entry without a cost renders the same + // as no cost, so skip the redundant snapshot. + if (existing?.CostUsd is null && costUsd <= 0) + return null; threadMetadata[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with { CostUsd = costUsd, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index dea1ce044..82939f02a 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -19,22 +19,19 @@ namespace OpenClawTray.Chat; /// when the model is unknown. /// /// -/// Cumulative input (prompt) tokens reported by the gateway for this turn, -/// shown in the footer with an up arrow (↑). null when not -/// reported (most live ``chat`` deltas don't carry usage info — only the -/// final summary does). +/// Cumulative input (prompt) tokens reported by the gateway for this turn. +/// null when not reported (most live ``chat`` deltas don't carry usage +/// info — only the final summary does). /// /// -/// Cumulative output tokens reported by the gateway for this turn, shown in -/// the footer with a down arrow (↓). +/// Cumulative output tokens reported by the gateway for this turn. /// /// -/// Total tokens spent on the response (prompt + completion) — surfaces as -/// R<n> in the footer (e.g. R45.4k). +/// Total tokens spent on the response (prompt + completion). /// /// /// Percentage of the model's context window consumed by the conversation -/// when this entry was generated (0–100). Shown as 23% ctx. +/// when this entry was generated (0–100). /// /// /// Gateway-reported cost in USD for this assistant entry diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs index 4725d9871..44436b54f 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs @@ -91,9 +91,13 @@ public static class ChatUsageFormatter /// /// Formats the gateway-reported per-message cost for the assistant footer /// using the same precision as the gateway's Control UI (4 decimals below - /// $0.01, 3 below $1, otherwise 2), e.g. $0.588. Values that would - /// round to zero render as <$0.0001. Missing, zero, negative, - /// or non-finite values are hidden. + /// $0.01, 3 below $1, otherwise 2), e.g. $0.588. The precision rule + /// mirrors formatCost in ui/src/lib/format.ts of + /// openclaw/openclaw (commit 0acc55cfa8a973f9d1764f7641f41e5a38e88abe). + /// Two deliberate divergences: zero is hidden instead of rendered as + /// $0.00, and values that would round to zero at four decimals + /// (below $0.00005) render as <$0.0001. Missing, negative, or + /// non-finite values are hidden. /// public static string? FormatCost(double? costUsd) { diff --git a/tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs b/tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs deleted file mode 100644 index efae10729..000000000 --- a/tests/OpenClaw.Shared.Tests/ChatMessageCostTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Reflection; -using System.Text.Json; -using Xunit; - -namespace OpenClaw.Shared.Tests; - -public class ChatMessageCostTests -{ - [Theory] - [InlineData("{\"usage\":{\"cost\":{\"total\":0.22503}}}", 0.22503)] - [InlineData("{\"usage\":{\"cost\":{\"total\":12}}}", 12.0)] - [InlineData("{\"usage\":{\"cost\":{\"total\":0}}}", 0.0)] - [InlineData("{}", null)] - [InlineData("{\"usage\":{\"cost\":0.5}}", null)] - [InlineData("{\"usage\":{\"cost\":{\"total\":-1}}}", null)] - [InlineData("{\"usage\":{\"cost\":{\"total\":\"bad\"}}}", null)] - public void ReadsReportedCostAndRejectsInvalidValues(string json, double? expected) - { - using var document = JsonDocument.Parse(json); - var method = typeof(OpenClawGatewayClient).GetMethod("ExtractChatCost", BindingFlags.NonPublic | BindingFlags.Static)!; - Assert.Equal(expected, (double?)method.Invoke(null, new object[] { document.RootElement })); - } -} diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 4fa32b6b8..b6898ea7e 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -2668,6 +2668,58 @@ public void ChatEvent_LegacyMediaOnly_RaisesTypedMessageWithoutRawPath() Assert.Equal("banner.png", media?.FileName); } + [Fact] + public void ParseChatHistoryPayload_AssistantRow_ReadsUsageCost() + { + var helper = new GatewayClientTestHelper(); + + var history = helper.ParseChatHistoryPayload(""" + { + "messages": [ + { + "role": "assistant", + "content": "priced reply", + "timestamp": 1, + "usage": { "input": 20, "output": 7, "cost": { "total": 0.22503 } } + }, + { + "role": "assistant", + "content": "unpriced reply", + "timestamp": 2, + "usage": { "input": 20, "output": 7 } + } + ] + } + """); + + Assert.Equal(2, history.Messages.Count); + Assert.Equal(0.22503, history.Messages[0].CostUsd); + Assert.Null(history.Messages[1].CostUsd); + } + + [Theory] + [InlineData("\"usage\": { \"cost\": { \"total\": 12 } }", 12.0)] + [InlineData("\"usage\": { \"cost\": { \"total\": 0 } }", 0.0)] + [InlineData("\"usage\": { \"cost\": 0.5 }", null)] + [InlineData("\"usage\": { \"cost\": { \"total\": -1 } }", null)] + [InlineData("\"usage\": { \"cost\": { \"total\": \"bad\" } }", null)] + [InlineData("\"usage\": {}", null)] + public void ParseChatHistoryPayload_AssistantRow_RejectsInvalidUsageCost(string usageJson, double? expected) + { + var helper = new GatewayClientTestHelper(); + + var history = helper.ParseChatHistoryPayload($$""" + { + "messages": [ + { "role": "assistant", "content": "reply", "timestamp": 1, {{usageJson}} } + ] + } + """); + + var message = Assert.Single(history.Messages); + Assert.Equal(expected, message.CostUsd); + } + [Fact] public void ParseChatHistoryPayload_OpenClawMetadata_PreservesMessageIdentity() { @@ -2903,6 +2955,32 @@ public void ProcessRawMessage_LegacyAssistantNotification_DependsOnFinalState( Assert.Equal(expectNotification ? 1 : 0, notifications.Count); } + [Fact] + public void ProcessRawMessage_LegacyAssistantMessage_ReadsPayloadUsageCost() + { + var helper = new GatewayClientTestHelper(); + ChatMessageInfo? received = null; + helper.Client.ChatMessageReceived += (_, message) => received = message; + + helper.ProcessRawMessage(""" + { + "type": "event", + "event": "session.message", + "payload": { + "sessionKey": "main", + "role": "assistant", + "text": "legacy reply", + "state": "final", + "usage": { "cost": { "total": 0.4 } } + } + } + """); + + Assert.NotNull(received); + Assert.Equal("legacy reply", received!.Text); + Assert.Equal(0.4, received.CostUsd); + } + [Fact] public void ProcessRawMessage_AgentEventLogsRawLengthWithoutPayloadContent() { diff --git a/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs index 88bb13be9..5365622fc 100644 --- a/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs @@ -6,8 +6,10 @@ public sealed class ChatCostFooterContractTests public void AssistantFooter_RendersCostThroughSharedFormatter() { var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ReactorChatTimeline.cs"); - var footer = timeline[timeline.IndexOf("private static string FooterText(", StringComparison.Ordinal).. - timeline.IndexOf("private static Element UserMetadata(", StringComparison.Ordinal)]; + var start = timeline.IndexOf("private static string FooterText(", StringComparison.Ordinal); + var end = timeline.IndexOf("private static Element UserMetadata(", StringComparison.Ordinal); + Assert.True(start >= 0 && end > start, "FooterText must precede UserMetadata in ReactorChatTimeline.cs"); + var footer = timeline[start..end]; // The footer gets its cost text from the shared formatter so the // precision rule is decided in one tested place. diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index fd5b80d55..86769a4c5 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -14830,7 +14830,7 @@ public async Task ChatMessageReceived_CostOnlyFinal_AttachesCostToLatestAssistan [Fact] public async Task ChatMessageReceived_DuplicateAssistantFinalWithCost_KeepsSingleCostAndUsage() { - var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); await provider.LoadAsync(); var message = new ChatMessageInfo @@ -14847,12 +14847,56 @@ public async Task ChatMessageReceived_DuplicateAssistantFinalWithCost_KeepsSingl }; bridge.RaiseChat(message); + var snapshotsAfterFirst = snapshots.Count; bridge.RaiseChat(message); + var duplicatePublishesWithCost = snapshots.Count - snapshotsAfterFirst; var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); var meta = provider.GetEntryMetadata("main"); Assert.Equal(0.22503, meta[entry.Id].CostUsd); Assert.Equal(27, meta[entry.Id].ResponseTokens); + + // The same duplicate without a cost is the baseline: an identical cost + // must not add a snapshot on top of the usual duplicate publishes. + var (baselineBridge, baselineProvider, baselineSnapshots, _) = CreateProvider(new[] { MainSession() }); + await baselineProvider.LoadAsync(); + var uncosted = new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "same", + State = "final", + Ts = 1714600005000, + InputTokens = 20, + OutputTokens = 7, + ResponseTokens = 27, + }; + baselineBridge.RaiseChat(uncosted); + var baselineAfterFirst = baselineSnapshots.Count; + baselineBridge.RaiseChat(uncosted); + Assert.Equal(baselineSnapshots.Count - baselineAfterFirst, duplicatePublishesWithCost); + } + + [Fact] + public async Task ChatMessageReceived_ZeroCostOnFreshEntry_LeavesCostUnset() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "free", + State = "final", + Ts = 1714600005000, + CostUsd = 0.0, + }); + + var entry = Assert.Single((await provider.LoadAsync()).Timelines["main"].Entries); + var meta = provider.GetEntryMetadata("main"); + // Zero renders like no cost, so it is not stored (and not snapshotted). + Assert.Null(meta[entry.Id].CostUsd); } [Fact] From 7aaaa5be909adccc5c711151f2675b52966958c7 Mon Sep 17 00:00:00 2001 From: zenpasdan1 Date: Sat, 10 Oct 2026 22:47:55 +0300 Subject: [PATCH 3/5] test(chat): drop source-text footer contract test The test pinned method ordering and formatter fragments and would still pass if the cost were excluded from the rendered footer. The formatter and provider tests cover the behavior. Co-Authored-By: Claude Opus 5.5 --- .../ChatCostFooterContractTests.cs | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs diff --git a/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs deleted file mode 100644 index 5365622fc..000000000 --- a/tests/OpenClaw.Tray.Tests/ChatCostFooterContractTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace OpenClaw.Tray.Tests; - -public sealed class ChatCostFooterContractTests -{ - [Fact] - public void AssistantFooter_RendersCostThroughSharedFormatter() - { - var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ReactorChatTimeline.cs"); - var start = timeline.IndexOf("private static string FooterText(", StringComparison.Ordinal); - var end = timeline.IndexOf("private static Element UserMetadata(", StringComparison.Ordinal); - Assert.True(start >= 0 && end > start, "FooterText must precede UserMetadata in ReactorChatTimeline.cs"); - var footer = timeline[start..end]; - - // The footer gets its cost text from the shared formatter so the - // precision rule is decided in one tested place. - Assert.Contains("ChatUsageFormatter.FormatCost(", footer); - Assert.Contains("metadata?.CostUsd", footer); - } - - private static string Read(params string[] parts) - => File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(parts).ToArray())); -} From bd205a992f4abb11eb378a06d06d7547c9266fde Mon Sep 17 00:00:00 2001 From: zenpasdan1 Date: Sat, 10 Oct 2026 22:52:26 +0300 Subject: [PATCH 4/5] fix(chat): show a split history row's cost on one replay entry A chat.history assistant row with interleaved text and tool parts replays as several timeline entries. Its single usage cost was copied onto every one of them, so one charge rendered at each assistant run end. Attach it to the row's last text part only. Co-Authored-By: Claude Opus 5.5 --- .../Chat/ChatHistoryLoader.cs | 20 ++++++++- .../OpenClawChatDataProviderTests.cs | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs index 1d40e1b43..adfb4038d 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs @@ -518,6 +518,21 @@ next.ActiveAssistantId is { } assistantId && .Select(static tool => tool.CallId) .Where(static callId => !string.IsNullOrWhiteSpace(callId)) .ToHashSet(StringComparer.Ordinal); + // One history row can replay as several entries (text, tool, text) but + // reports a single cost. Show it once, on the row's last text part. + var costCarriers = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach (var part in replayParts) + { + if (part.Message.CostUsd is null) + continue; + if (!costCarriers.TryGetValue(part.Message, out var carrier) || + part.Text.Length > 0 || + carrier.Text.Length == 0) + { + costCarriers[part.Message] = part; + } + } string AllocateSyntheticToolCallId() { @@ -603,7 +618,10 @@ string AllocateSyntheticToolCallId() message.OutputTokens, message.ResponseTokens, message.ContextPercent, - CostUsd: message.CostUsd, + CostUsd: costCarriers.TryGetValue(message, out var costCarrier) && + ReferenceEquals(costCarrier, replayPart) + ? message.CostUsd + : null, GatewayMessageId: message.OpenClawId, OpenClawSeq: message.OpenClawSeq, OpenClawKind: message.OpenClawKind, diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 86769a4c5..0210ae1c8 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -15124,6 +15124,51 @@ public async Task LoadHistoryAsync_CapturesAssistantCost() Assert.Equal(0.22503, meta[entries[1].Id].CostUsd); } + [Fact] + public async Task LoadHistoryAsync_SplitAssistantRow_ShowsCostOnceOnLastTextPart() + { + var call = new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "call-cost", + ToolName = "exec", + }; + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Text = "Before\nAfter", + State = "final", + Ts = 1714600001000, + CostUsd = 0.5, + ToolContent = [call], + ContentParts = + [ + new ChatMessageContentPartInfo { Kind = ChatMessageContentPartKind.Text, Text = "Before" }, + new ChatMessageContentPartInfo { Kind = ChatMessageContentPartKind.Tool, Tool = call }, + new ChatMessageContentPartInfo { Kind = ChatMessageContentPartKind.Text, Text = "After" }, + ], + }, + ], + }); + await provider.LoadAsync(); + + await provider.LoadHistoryAsync("main"); + + var entries = (await provider.LoadAsync()).Timelines["main"].Entries; + var meta = provider.GetEntryMetadata("main"); + var priced = entries.Where(entry => meta.TryGetValue(entry.Id, out var m) && m.CostUsd is not null).ToArray(); + var only = Assert.Single(priced); + Assert.Equal(ChatTimelineItemKind.Assistant, only.Kind); + Assert.Equal("After", only.Text); + Assert.Equal(0.5, meta[only.Id].CostUsd); + } + [Fact] public async Task LoadHistoryAsync_HistoryRowWithoutCost_ReplacesLiveCost() { From b91055a30c89e89556412987d3906cad45ec9818 Mon Sep 17 00:00:00 2001 From: zenpasdan1 Date: Mon, 12 Oct 2026 16:13:38 +0300 Subject: [PATCH 5/5] fix(chat): keep a history row's cost on its final assistant entry The cost carrier preferred a text part over a later media part. The footer renders on the run's final assistant entry, so a text then image row kept the cost on the earlier text entry and hid it after reload. Attach it to the last part that renders an assistant entry, text or media. Co-Authored-By: Claude Opus 5.5 --- .../Chat/ChatHistoryLoader.cs | 11 +++-- .../OpenClawChatDataProviderTests.cs | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs index adfb4038d..914f6d27a 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs @@ -518,8 +518,11 @@ next.ActiveAssistantId is { } assistantId && .Select(static tool => tool.CallId) .Where(static callId => !string.IsNullOrWhiteSpace(callId)) .ToHashSet(StringComparer.Ordinal); - // One history row can replay as several entries (text, tool, text) but - // reports a single cost. Show it once, on the row's last text part. + // One history row can replay as several entries (text, tool, media) but + // reports a single cost. Show it once, on the row's last assistant + // entry (text or media), which is where the footer renders. + static bool RendersAssistantEntry(ChatHistoryReplayPart part) => + part.Text.Length > 0 || part.AssistantContentParts.Count > 0; var costCarriers = new Dictionary( ReferenceEqualityComparer.Instance); foreach (var part in replayParts) @@ -527,8 +530,8 @@ next.ActiveAssistantId is { } assistantId && if (part.Message.CostUsd is null) continue; if (!costCarriers.TryGetValue(part.Message, out var carrier) || - part.Text.Length > 0 || - carrier.Text.Length == 0) + RendersAssistantEntry(part) || + !RendersAssistantEntry(carrier)) { costCarriers[part.Message] = part; } diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 0210ae1c8..030aaad87 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -15169,6 +15169,54 @@ public async Task LoadHistoryAsync_SplitAssistantRow_ShowsCostOnceOnLastTextPart Assert.Equal(0.5, meta[only.Id].CostUsd); } + [Fact] + public async Task LoadHistoryAsync_TextThenMediaRow_ShowsCostOnceOnFinalMediaEntry() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Text = "Here it is", + State = "final", + Ts = 1714600001000, + CostUsd = 0.5, + ContentParts = + [ + new ChatMessageContentPartInfo { Kind = ChatMessageContentPartKind.Text, Text = "Here it is" }, + new ChatMessageContentPartInfo + { + Kind = ChatMessageContentPartKind.Media, + Media = new ChatMediaContentInfo + { + Kind = ChatMediaContentKind.Image, + Source = ChatMediaContentSource.Structured, + ArtifactId = "artifact-cost", + FileName = "banner.png", + }, + }, + ], + }, + ], + }); + await provider.LoadAsync(); + + await provider.LoadHistoryAsync("main"); + + var entries = (await provider.LoadAsync()).Timelines["main"].Entries; + var assistantEntries = entries.Where(entry => entry.Kind == ChatTimelineItemKind.Assistant).ToArray(); + Assert.Equal(2, assistantEntries.Length); + var meta = provider.GetEntryMetadata("main"); + var priced = entries.Where(entry => meta.TryGetValue(entry.Id, out var m) && m.CostUsd is not null).ToArray(); + var only = Assert.Single(priced); + Assert.Equal(assistantEntries[^1].Id, only.Id); + Assert.Equal(0.5, meta[only.Id].CostUsd); + } + [Fact] public async Task LoadHistoryAsync_HistoryRowWithoutCost_ReplacesLiveCost() {