mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 01:01:37 +00:00
* feat(ios): show per-message usage metadata * fix(ios): compile usage context calculation
281 lines
9.3 KiB
Swift
281 lines
9.3 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import OpenClawChatUI
|
|
|
|
private final class ContextUsageTestTransport: @unchecked Sendable, OpenClawChatTransport {
|
|
func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload {
|
|
throw CancellationError()
|
|
}
|
|
|
|
func sendMessage(
|
|
sessionKey _: String,
|
|
message _: String,
|
|
thinking _: String,
|
|
idempotencyKey _: String,
|
|
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
|
{
|
|
throw CancellationError()
|
|
}
|
|
|
|
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
|
false
|
|
}
|
|
|
|
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
|
AsyncStream { continuation in
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ChatContextUsageTests {
|
|
private func message(
|
|
role: String = "assistant",
|
|
usage: OpenClawChatUsage? = nil) -> OpenClawChatMessage
|
|
{
|
|
OpenClawChatMessage(
|
|
id: UUID(),
|
|
role: role,
|
|
content: [OpenClawChatMessageContent(
|
|
type: "text",
|
|
text: "hi",
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: nil,
|
|
fileName: nil,
|
|
content: nil,
|
|
id: nil,
|
|
name: nil,
|
|
arguments: nil)],
|
|
timestamp: nil,
|
|
usage: usage)
|
|
}
|
|
|
|
private func usage(
|
|
input: Int? = nil,
|
|
output: Int? = nil,
|
|
cacheRead: Int? = nil,
|
|
cacheWrite: Int? = nil,
|
|
total: Int? = nil,
|
|
costTotal: Double? = nil) throws -> OpenClawChatUsage
|
|
{
|
|
var payload: [String: Any] = [:]
|
|
payload["input"] = input
|
|
payload["output"] = output
|
|
payload["cacheRead"] = cacheRead
|
|
payload["cacheWrite"] = cacheWrite
|
|
payload["total"] = total
|
|
if let costTotal {
|
|
payload["cost"] = ["total": costTotal]
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: payload.compactMapValues { $0 })
|
|
return try JSONDecoder().decode(OpenClawChatUsage.self, from: data)
|
|
}
|
|
|
|
@Test func `uses newest usage-bearing message, not a sum of runs`() throws {
|
|
let messages = try [
|
|
self.message(usage: self.usage(total: 900)),
|
|
self.message(role: "user"),
|
|
self.message(usage: self.usage(total: 1200)),
|
|
]
|
|
let result = ChatContextUsageCalculator.usage(
|
|
messages: messages,
|
|
sessionEntry: nil,
|
|
defaults: nil,
|
|
modelContextWindow: 4000)
|
|
|
|
#expect(result?.usedTokens == 1200)
|
|
#expect(result?.contextWindowTokens == 4000)
|
|
#expect(result?.percentUsed == 30)
|
|
}
|
|
|
|
@Test func `sums usage components when total is missing`() throws {
|
|
let messages = try [self.message(usage: self.usage(input: 700, output: 100, cacheRead: 200))]
|
|
let result = ChatContextUsageCalculator.usage(
|
|
messages: messages,
|
|
sessionEntry: nil,
|
|
defaults: nil,
|
|
modelContextWindow: nil)
|
|
|
|
#expect(result?.usedTokens == 1000)
|
|
#expect(result?.contextWindowTokens == nil)
|
|
#expect(result?.fractionUsed == nil)
|
|
}
|
|
|
|
@Test func `falls back to session totals without message usage`() {
|
|
let entry = OpenClawChatSessionEntry(
|
|
key: "main",
|
|
kind: nil,
|
|
displayName: nil,
|
|
surface: nil,
|
|
subject: nil,
|
|
room: nil,
|
|
space: nil,
|
|
updatedAt: nil,
|
|
sessionId: nil,
|
|
systemSent: nil,
|
|
abortedLastRun: nil,
|
|
thinkingLevel: nil,
|
|
verboseLevel: nil,
|
|
inputTokens: nil,
|
|
outputTokens: nil,
|
|
totalTokens: 5000,
|
|
modelProvider: nil,
|
|
model: nil,
|
|
contextTokens: 10000)
|
|
let result = ChatContextUsageCalculator.usage(
|
|
messages: [self.message()],
|
|
sessionEntry: entry,
|
|
defaults: nil,
|
|
modelContextWindow: nil)
|
|
|
|
#expect(result?.usedTokens == 5000)
|
|
#expect(result?.contextWindowTokens == 10000)
|
|
#expect(result?.percentUsed == 50)
|
|
}
|
|
|
|
@Test func `ignores stale session totals without message usage`() {
|
|
let entry = OpenClawChatSessionEntry(
|
|
key: "main",
|
|
kind: nil,
|
|
displayName: nil,
|
|
surface: nil,
|
|
subject: nil,
|
|
room: nil,
|
|
space: nil,
|
|
updatedAt: nil,
|
|
sessionId: nil,
|
|
systemSent: nil,
|
|
abortedLastRun: nil,
|
|
thinkingLevel: nil,
|
|
verboseLevel: nil,
|
|
inputTokens: nil,
|
|
outputTokens: nil,
|
|
totalTokens: 5000,
|
|
totalTokensFresh: false,
|
|
modelProvider: nil,
|
|
model: nil,
|
|
contextTokens: 10000)
|
|
let result = ChatContextUsageCalculator.usage(
|
|
messages: [self.message()],
|
|
sessionEntry: entry,
|
|
defaults: nil,
|
|
modelContextWindow: nil)
|
|
|
|
#expect(result == nil)
|
|
}
|
|
|
|
@Test func `sums cost across all runs`() throws {
|
|
let messages = try [
|
|
self.message(usage: self.usage(total: 100, costTotal: 0.25)),
|
|
self.message(usage: self.usage(total: 200, costTotal: 0.5)),
|
|
]
|
|
let result = ChatContextUsageCalculator.usage(
|
|
messages: messages,
|
|
sessionEntry: nil,
|
|
defaults: nil,
|
|
modelContextWindow: nil)
|
|
|
|
#expect(result?.totalCost == 0.75)
|
|
}
|
|
|
|
@Test func `no usage anywhere yields nil`() {
|
|
let result = ChatContextUsageCalculator.usage(
|
|
messages: [self.message()],
|
|
sessionEntry: nil,
|
|
defaults: nil,
|
|
modelContextWindow: 4000)
|
|
|
|
#expect(result == nil)
|
|
}
|
|
|
|
@Test @MainActor func `view model resolves context totals through a selected global alias`() {
|
|
let vm = OpenClawChatViewModel(
|
|
sessionKey: "global",
|
|
transport: ContextUsageTestTransport(),
|
|
activeAgentId: "ops")
|
|
vm.sessions = [OpenClawChatSessionEntry(
|
|
key: "agent:ops:global",
|
|
kind: nil,
|
|
displayName: nil,
|
|
surface: nil,
|
|
subject: nil,
|
|
room: nil,
|
|
space: nil,
|
|
updatedAt: nil,
|
|
sessionId: nil,
|
|
systemSent: nil,
|
|
abortedLastRun: nil,
|
|
thinkingLevel: nil,
|
|
verboseLevel: nil,
|
|
inputTokens: nil,
|
|
outputTokens: nil,
|
|
totalTokens: 5000,
|
|
modelProvider: nil,
|
|
model: nil,
|
|
contextTokens: 10000)]
|
|
|
|
#expect(vm.contextUsage?.usedTokens == 5000)
|
|
#expect(vm.contextUsage?.contextWindowTokens == 10000)
|
|
}
|
|
|
|
@Test func `assistant message usage mirrors the compact control UI footer`() throws {
|
|
let message = try self.message(usage: self.usage(
|
|
input: 59,
|
|
output: 13400,
|
|
cacheRead: 2_200_000,
|
|
cacheWrite: 43900,
|
|
costTotal: 0.01234))
|
|
|
|
let presentation = try #require(ChatMessageUsagePresentation.make(
|
|
message: message,
|
|
contextWindowTokens: 3_000_000))
|
|
|
|
#expect(presentation.text == "↑59 ↓13.4k R2.2M W43.9k $0.0123 ⚠︎ 75% ctx")
|
|
#expect(presentation.pressure == .warning)
|
|
#expect(presentation.accessibilityValue.contains("Warning"))
|
|
}
|
|
|
|
@Test func `context pressure excludes output tokens and clamps at one hundred percent`() throws {
|
|
let warning = try self.message(usage: self.usage(
|
|
input: 700,
|
|
output: 900_000,
|
|
cacheRead: 50,
|
|
cacheWrite: 50))
|
|
let danger = try self.message(usage: self.usage(input: 1200, output: 900_000))
|
|
|
|
let warningPresentation = try #require(ChatMessageUsagePresentation.make(
|
|
message: warning,
|
|
contextWindowTokens: 1000))
|
|
let dangerPresentation = try #require(ChatMessageUsagePresentation.make(
|
|
message: danger,
|
|
contextWindowTokens: 1000))
|
|
|
|
#expect(warningPresentation.text.hasSuffix("⚠︎ 80% ctx"))
|
|
#expect(warningPresentation.pressure == .warning)
|
|
#expect(dangerPresentation.text.hasSuffix("⚠︎ 100% ctx"))
|
|
#expect(dangerPresentation.pressure == .danger)
|
|
#expect(dangerPresentation.accessibilityValue.contains("Critical"))
|
|
}
|
|
|
|
@Test func `extreme decoded usage clamps without integer overflow`() throws {
|
|
let message = try self.message(usage: self.usage(input: Int.max, cacheRead: 1))
|
|
|
|
let presentation = try #require(ChatMessageUsagePresentation.make(
|
|
message: message,
|
|
contextWindowTokens: 1))
|
|
|
|
#expect(presentation.text.hasSuffix("⚠︎ 100% ctx"))
|
|
#expect(presentation.pressure == .danger)
|
|
}
|
|
|
|
@Test func `usage footer is assistant-only and omits unsupported totals`() throws {
|
|
let usage = try self.usage(total: 1200)
|
|
let user = self.message(role: "user", usage: usage)
|
|
let assistant = self.message(usage: usage)
|
|
|
|
#expect(ChatMessageUsagePresentation.make(message: user, contextWindowTokens: 4000) == nil)
|
|
#expect(ChatMessageUsagePresentation.make(message: assistant, contextWindowTokens: 4000) == nil)
|
|
}
|
|
}
|