diff --git a/docs/CHAT_VISUAL_DESIGN.md b/docs/CHAT_VISUAL_DESIGN.md index 1052413051..99025efacb 100644 --- a/docs/CHAT_VISUAL_DESIGN.md +++ b/docs/CHAT_VISUAL_DESIGN.md @@ -90,6 +90,20 @@ action visible without hovering. Provider promotion replaces the pending row wit without adding queue-only rows to history. This applies to Workspace's ChatPage. 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 (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 +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 bbbd8ee41c..5fe59cbe80 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -81,7 +81,7 @@ stable, prerelease, and main invocations can upload in the same caller run. - **Command output-drain timing** - The unsupported-Windows runner fixture gates launcher release, then requires completion within three seconds of root exit while a descendant retains stdout, stderr, or both. Ten repetitions per case check short output and PID/start-time-bound child cleanup. The nonparallel `MxcOwnership` collection isolates these wall-clock budgets from unrelated tests. Native SDK finalization has separate managed ownership tests and opt-in runtime proof. - **Fixture process identity** - MXC pipe-fixture metadata, cleanup ownership checks, and the opt-in native MXC descendant-identity check query the observed full executable image through a retained process handle, not the loader's nullable `MainModule` or a requested launch path. `ProcessImagePathTests` checks a real, never-resumed Windows process with no initialized loader view, a different command-line image name, caller-handle lifetime, and explicit native query failure. This deterministically covers unavailable loader metadata; it does not reproduce the timing of a normally scheduled child or prove native MXC containment. -- **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. The signing-identity regression covers native and managed loopback WebSockets with a bounded client-initiated disconnect and a queued health request. The fixture drains application frames already in flight until the client's close frame, replies, and closes its transport under one deadline rather than relying on the client's EOF fallback timer. A separate fixture regression checks the close frame and actual TCP EOF on both transports, including fragmented text and binary data queued before close. - **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. diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 02e6eb6fd0..408dc29b0c 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1854,6 +1854,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 f640c8b9ca..91965ade19 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -809,6 +809,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, @@ -819,6 +820,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; @@ -4182,7 +4194,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) messageOpenClawMetadata.TokensBefore ?? payloadOpenClawMetadata.TokensBefore, messageOpenClawMetadata.TokensAfter ?? payloadOpenClawMetadata.TokensAfter, contentParts, - runId); + runId, + costUsd: ExtractChatCost(message) ?? ExtractChatCost(payload)); if (notify && role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase)) { @@ -4224,7 +4237,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) openClawMetadata.TokensBefore, openClawMetadata.TokensAfter, projection.ContentParts, - runId); + runId, + costUsd: ExtractChatCost(payload)); if (notify && role == "assistant" && (string.IsNullOrWhiteSpace(state) || @@ -4305,7 +4319,8 @@ private bool EmitChatMessageReceived( long? compactionTokensBefore = null, long? compactionTokensAfter = null, IReadOnlyList? contentParts = null, - string? runId = null) + string? runId = null, + double? costUsd = null) { if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return false; @@ -4322,6 +4337,7 @@ private bool 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 3dcd5c7b1c..e7a42e8c5f 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs @@ -2341,6 +2341,44 @@ 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; + // 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, + }; + return BuildSnapshotLocked(context); + } + return null; + } + } + internal ChatDataSnapshot? SnapshotAssistantUsageFrame( string threadId, ChatEntryMetadata metadata, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index c9a131352f..98afbd0b12 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 @@ -19,22 +19,24 @@ 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 +/// (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 @@ -75,6 +77,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 4b052e934a..46e2071b9c 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs @@ -543,6 +543,24 @@ 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, 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) + { + if (part.Message.CostUsd is null) + continue; + if (!costCarriers.TryGetValue(part.Message, out var carrier) || + RendersAssistantEntry(part) || + !RendersAssistantEntry(carrier)) + { + costCarriers[part.Message] = part; + } + } string AllocateSyntheticToolCallId() { @@ -644,6 +662,10 @@ string AllocateSyntheticToolCallId() message.OutputTokens, message.ResponseTokens, message.ContextPercent, + 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/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs index dbdb7cced3..44436b54ff 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs @@ -88,6 +88,27 @@ 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. 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) + { + 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 4fc33c2037..f61a1d6ff4 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -1499,6 +1499,12 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) 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 e73b25a3c3..f54f23cd75 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs @@ -1065,9 +1065,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/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index d2d3ef33a2..5f5d84a1d0 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -2163,6 +2163,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() { @@ -2780,6 +2867,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() { @@ -3150,6 +3289,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/ChatUsageFormatterTests.cs b/tests/OpenClaw.Tray.Tests/ChatUsageFormatterTests.cs index 452363efff..9ec6054a0d 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 f9a9dbdd77..6c2ba7020a 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -16380,6 +16380,519 @@ 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, snapshots, _) = 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); + 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] + 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_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_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() + { + 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)