diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json
index c45dd9112dfe..c80ca7692c43 100644
--- a/apps/.i18n/native-source.json
+++ b/apps/.i18n/native-source.json
@@ -40083,7 +40083,7 @@
},
{
"kind": "ui-modifier",
- "line": 247,
+ "line": 228,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift",
"source": "Context usage",
"surface": "apple",
@@ -40091,7 +40091,7 @@
},
{
"kind": "ui-localized-call",
- "line": 261,
+ "line": 242,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift",
"source": "%@ percent of the context window used",
"surface": "apple",
@@ -40099,7 +40099,7 @@
},
{
"kind": "ui-localized-call",
- "line": 265,
+ "line": 246,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift",
"source": "%@ tokens used",
"surface": "apple",
@@ -40435,7 +40435,7 @@
},
{
"kind": "ui-localized-call",
- "line": 39,
+ "line": 124,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "%@ avatar",
"surface": "apple",
@@ -40443,7 +40443,7 @@
},
{
"kind": "ui-localized-call",
- "line": 41,
+ "line": 126,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Agent avatar",
"surface": "apple",
@@ -40451,7 +40451,7 @@
},
{
"kind": "ui-localized-call",
- "line": 401,
+ "line": 486,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Additional images hidden: %lld",
"surface": "apple",
@@ -40459,7 +40459,7 @@
},
{
"kind": "conditional-branch",
- "line": 441,
+ "line": 526,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Show less",
"surface": "apple",
@@ -40467,7 +40467,7 @@
},
{
"kind": "conditional-branch",
- "line": 441,
+ "line": 526,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Show more",
"surface": "apple",
@@ -40475,7 +40475,7 @@
},
{
"kind": "conditional-branch",
- "line": 455,
+ "line": 540,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Collapsed",
"surface": "apple",
@@ -40483,7 +40483,7 @@
},
{
"kind": "conditional-branch",
- "line": 455,
+ "line": 540,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Expanded",
"surface": "apple",
@@ -40491,7 +40491,7 @@
},
{
"kind": "ui-localized-call",
- "line": 475,
+ "line": 560,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Message usage",
"surface": "apple",
@@ -40499,7 +40499,7 @@
},
{
"kind": "conditional-branch",
- "line": 702,
+ "line": 787,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Voice note",
"surface": "apple",
@@ -40507,7 +40507,7 @@
},
{
"kind": "ui-localized-call",
- "line": 743,
+ "line": 828,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Attachment",
"surface": "apple",
@@ -40515,7 +40515,7 @@
},
{
"kind": "ui-call",
- "line": 783,
+ "line": 868,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Writing",
"surface": "apple",
@@ -40523,7 +40523,7 @@
},
{
"kind": "ui-call",
- "line": 822,
+ "line": 907,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio…",
"surface": "apple",
@@ -40531,7 +40531,7 @@
},
{
"kind": "ui-call",
- "line": 825,
+ "line": 910,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking…",
"surface": "apple",
@@ -40539,7 +40539,7 @@
},
{
"kind": "conditional-branch",
- "line": 833,
+ "line": 918,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio, tap to cancel",
"surface": "apple",
@@ -40547,7 +40547,7 @@
},
{
"kind": "conditional-branch",
- "line": 834,
+ "line": 919,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking, tap to stop",
"surface": "apple",
@@ -41715,7 +41715,7 @@
},
{
"kind": "conditional-branch",
- "line": 80,
+ "line": 90,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
"source": "Chat transcript",
"surface": "apple",
@@ -41723,7 +41723,7 @@
},
{
"kind": "conditional-branch",
- "line": 102,
+ "line": 112,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
"source": "Attachment",
"surface": "apple",
@@ -41731,15 +41731,79 @@
},
{
"kind": "conditional-branch",
- "line": 122,
+ "line": 144,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
"source": "Message",
"surface": "apple",
"id": "native.apple.0149100bb5c8b985"
},
+ {
+ "kind": "ui-localized-call",
+ "line": 19,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "System · restart recovery",
+ "surface": "apple",
+ "id": "native.apple.041f2ed87f1797a0"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 21,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "System · gateway restarted",
+ "surface": "apple",
+ "id": "native.apple.0eaa9e1136018332"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 23,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "System",
+ "surface": "apple",
+ "id": "native.apple.25ffcf5fcb1645d3"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 46,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "Compacted history",
+ "surface": "apple",
+ "id": "native.apple.55c9f51c550018a9"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 48,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "Session reset",
+ "surface": "apple",
+ "id": "native.apple.28264f2dcf55651b"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 55,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "saved %@ tokens",
+ "surface": "apple",
+ "id": "native.apple.6410db19dcb02d59"
+ },
+ {
+ "kind": "ui-localized-call",
+ "line": 64,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "The earlier conversation was cleared.",
+ "surface": "apple",
+ "id": "native.apple.0b97fe0abe7ea144"
+ },
+ {
+ "kind": "ui-localized-call-multiline",
+ "line": 132,
+ "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
+ "source": "Turn interrupted by a gateway restart — asked the agent to resume and finish the response.",
+ "surface": "apple",
+ "id": "native.apple.4efcef39bcaea3d1"
+ },
{
"kind": "ui-call",
- "line": 592,
+ "line": 601,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Retry Send",
"surface": "apple",
@@ -41747,7 +41811,7 @@
},
{
"kind": "ui-call",
- "line": 606,
+ "line": 615,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Delete",
"surface": "apple",
@@ -41755,7 +41819,7 @@
},
{
"kind": "ui-call",
- "line": 642,
+ "line": 651,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Stop Listening",
"surface": "apple",
@@ -41763,7 +41827,7 @@
},
{
"kind": "ui-call",
- "line": 645,
+ "line": 654,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Listen",
"surface": "apple",
@@ -41771,7 +41835,7 @@
},
{
"kind": "ui-modifier",
- "line": 742,
+ "line": 750,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Jump to latest reply",
"surface": "apple",
@@ -41779,7 +41843,7 @@
},
{
"kind": "ui-named-argument",
- "line": 762,
+ "line": 770,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Refresh",
"surface": "apple",
@@ -41787,7 +41851,7 @@
},
{
"kind": "ui-call",
- "line": 1175,
+ "line": 1181,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Copy Message",
"surface": "apple",
@@ -41795,7 +41859,7 @@
},
{
"kind": "ui-call",
- "line": 1198,
+ "line": 1204,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Open Full Message",
"surface": "apple",
@@ -41803,7 +41867,7 @@
},
{
"kind": "ui-call",
- "line": 1217,
+ "line": 1223,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Rewind to Here",
"surface": "apple",
@@ -41811,7 +41875,7 @@
},
{
"kind": "ui-call",
- "line": 1237,
+ "line": 1243,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Fork from Here",
"surface": "apple",
@@ -41819,7 +41883,7 @@
},
{
"kind": "ui-localized-call",
- "line": 1263,
+ "line": 1269,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Reply",
"surface": "apple",
@@ -41827,7 +41891,7 @@
},
{
"kind": "ui-localized-call",
- "line": 1273,
+ "line": 1279,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "You",
"surface": "apple",
@@ -41835,7 +41899,7 @@
},
{
"kind": "ui-localized-call",
- "line": 1275,
+ "line": 1281,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Assistant",
"surface": "apple",
@@ -41843,7 +41907,7 @@
},
{
"kind": "ui-call",
- "line": 1341,
+ "line": 1347,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@@ -41851,7 +41915,7 @@
},
{
"kind": "ui-modifier",
- "line": 1421,
+ "line": 1427,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCompactTokenCountFormatter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCompactTokenCountFormatter.swift
new file mode 100644
index 000000000000..085dbdbdfd32
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCompactTokenCountFormatter.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+enum ChatCompactTokenCountFormatter {
+ static func string(_ tokens: Double) -> String {
+ if tokens >= 1_000_000 {
+ return "\(self.oneDecimal(tokens / 1_000_000))M"
+ }
+ if tokens >= 1000 {
+ let thousands = self.oneDecimal(tokens / 1000)
+ if Double(thousands) ?? 0 >= 1000 {
+ return "\(self.oneDecimal(tokens / 1_000_000))M"
+ }
+ return "\(thousands)k"
+ }
+ return String(Int(tokens))
+ }
+
+ private static func oneDecimal(_ value: Double) -> String {
+ let rounded = (value * 10).rounded(.toNearestOrAwayFromZero) / 10
+ let formatted = String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), rounded)
+ return formatted.hasSuffix(".0") ? String(formatted.dropLast(2)) : formatted
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift
index 949ef4d895f5..756b1378fa99 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift
@@ -118,25 +118,25 @@ struct ChatMessageUsagePresentation: Equatable {
let cacheWrite = self.positive(usage.cacheWrite)
if let input {
- visualParts.append("↑\(self.tokens(input))")
+ visualParts.append("↑\(ChatCompactTokenCountFormatter.string(Double(input)))")
accessibilityParts.append(String(
format: String(localized: "Input tokens: %@"),
input.formatted()))
}
if let output {
- visualParts.append("↓\(self.tokens(output))")
+ visualParts.append("↓\(ChatCompactTokenCountFormatter.string(Double(output)))")
accessibilityParts.append(String(
format: String(localized: "Output tokens: %@"),
output.formatted()))
}
if let cacheRead {
- visualParts.append("R\(self.tokens(cacheRead))")
+ visualParts.append("R\(ChatCompactTokenCountFormatter.string(Double(cacheRead)))")
accessibilityParts.append(String(
format: String(localized: "Cache read tokens: %@"),
cacheRead.formatted()))
}
if let cacheWrite {
- visualParts.append("W\(self.tokens(cacheWrite))")
+ visualParts.append("W\(ChatCompactTokenCountFormatter.string(Double(cacheWrite)))")
accessibilityParts.append(String(
format: String(localized: "Cache write tokens: %@"),
cacheWrite.formatted()))
@@ -197,25 +197,6 @@ struct ChatMessageUsagePresentation: Equatable {
guard let value, value > 0 else { return nil }
return value
}
-
- private static func tokens(_ value: Int) -> String {
- if value >= 1_000_000 {
- return "\(self.trimmedDecimal(Double(value) / 1_000_000))M"
- }
- if value >= 1000 {
- let thousands = Double(value) / 1000
- if thousands >= 999.95 {
- return "\(self.trimmedDecimal(Double(value) / 1_000_000))M"
- }
- return "\(self.trimmedDecimal(thousands))k"
- }
- return "\(value)"
- }
-
- private static func trimmedDecimal(_ value: Double) -> String {
- String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
- .replacingOccurrences(of: ".0", with: "")
- }
}
#if os(macOS)
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
index 2c0ec412c133..7713b6dac97b 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
@@ -2,6 +2,91 @@ import Foundation
import OpenClawKit
import SwiftUI
+struct ChatSystemNoticeRow: View {
+ let notice: ChatTranscriptRow.SystemNotice
+
+ var body: some View {
+ VStack(spacing: 8) {
+ ChatSystemLine(
+ systemImage: self.notice.systemImage,
+ label: self.notice.label,
+ metric: nil)
+ Text(self.notice.body)
+ .font(OpenClawChatTypography.footnote)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ }
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(verbatim: "\(self.notice.label), \(self.notice.body)"))
+ }
+}
+
+struct ChatHistoryDividerRow: View {
+ let divider: ChatTranscriptRow.HistoryDivider
+
+ var body: some View {
+ VStack(spacing: 6) {
+ ChatSystemLine(
+ systemImage: self.divider.systemImage,
+ label: self.divider.label,
+ metric: self.divider.metric)
+ if let description = self.divider.description {
+ Text(description)
+ .font(OpenClawChatTypography.caption)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(self.accessibilityLabel)
+ }
+
+ private var accessibilityLabel: String {
+ [self.divider.label, self.divider.metric, self.divider.description]
+ .compactMap(\.self)
+ .joined(separator: ", ")
+ }
+}
+
+private struct ChatSystemLine: View {
+ let systemImage: String
+ let label: String
+ let metric: String?
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Rectangle()
+ .fill(OpenClawChatTheme.divider)
+ .frame(height: 1)
+ HStack(spacing: 5) {
+ Image(systemName: self.systemImage)
+ .font(.system(size: 11, weight: .medium))
+ .accessibilityHidden(true)
+ Text(self.label.uppercased())
+ .font(OpenClawChatTypography.captionSemiBold)
+ .tracking(0.5)
+ if let metric {
+ Text("·")
+ .font(OpenClawChatTypography.caption)
+ .accessibilityHidden(true)
+ Text(metric)
+ .font(OpenClawChatTypography.caption)
+ .monospacedDigit()
+ }
+ }
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ Rectangle()
+ .fill(OpenClawChatTheme.divider)
+ .frame(height: 1)
+ }
+ }
+}
+
private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 560
static let bubbleCorner: CGFloat = 18
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift
index fb798cae2a0b..9329ed1184d6 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift
@@ -362,11 +362,51 @@ public struct OpenClawChatCanvasPreview: Codable, Hashable, Sendable {
}
}
+public struct OpenClawChatInputProvenance: Codable, Hashable, Sendable {
+ public let kind: String
+ public let originSessionId: String?
+ public let sourceSessionKey: String?
+ public let sourceChannel: String?
+ public let sourceTool: String?
+
+ // periphery:ignore - package tests construct provenance fixtures; app consumers decode this payload.
+ public init(
+ kind: String,
+ originSessionId: String? = nil,
+ sourceSessionKey: String? = nil,
+ sourceChannel: String? = nil,
+ sourceTool: String? = nil)
+ {
+ self.kind = kind
+ self.originSessionId = originSessionId
+ self.sourceSessionKey = sourceSessionKey
+ self.sourceChannel = sourceChannel
+ self.sourceTool = sourceTool
+ }
+}
+
+public struct OpenClawChatHistoryMarker: Codable, Hashable, Sendable {
+ public let kind: String
+ public let id: String?
+ public let tokensBefore: Double?
+ public let tokensAfter: Double?
+
+ public init(kind: String, id: String? = nil, tokensBefore: Double? = nil, tokensAfter: Double? = nil) {
+ self.kind = kind
+ self.id = id
+ self.tokensBefore = tokensBefore
+ self.tokensAfter = tokensAfter
+ }
+}
+
public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
private struct OpenClawMetadata: Codable {
+ let kind: String?
let id: String?
let idempotencyKey: String?
let truncated: Bool?
+ let tokensBefore: Double?
+ let tokensAfter: Double?
}
public var id: UUID = .init()
@@ -383,6 +423,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
public let errorMessage: String?
public let details: AnyCodable?
public let isError: Bool?
+ public let provenance: OpenClawChatInputProvenance?
+ public let historyMarker: OpenClawChatHistoryMarker?
enum CodingKeys: String, CodingKey {
case role
@@ -390,6 +432,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
case timestamp
case idempotencyKey
case openClaw = "__openclaw"
+ case provenance
case toolCallId
case tool_call_id
case toolName
@@ -420,7 +463,9 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
stopReason: String? = nil,
errorMessage: String? = nil,
details: AnyCodable? = nil,
- isError: Bool? = nil)
+ isError: Bool? = nil,
+ provenance: OpenClawChatInputProvenance? = nil,
+ historyMarker: OpenClawChatHistoryMarker? = nil)
{
self.id = id
self.transcriptMessageID = transcriptMessageID
@@ -436,6 +481,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
self.errorMessage = errorMessage
self.details = details
self.isError = isError
+ self.provenance = provenance
+ self.historyMarker = historyMarker
}
public init(from decoder: Decoder) throws {
@@ -457,6 +504,9 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
let decodedDetails = try container.decodeIfPresent(AnyCodable.self, forKey: .details)
let decodedIsError = try container.decodeIfPresent(Bool.self, forKey: .isError) ??
container.decodeIfPresent(Bool.self, forKey: .is_error)
+ let decodedProvenance = try? container.decode(
+ OpenClawChatInputProvenance.self,
+ forKey: .provenance)
self.role = decodedRole
self.transcriptMessageID = decodedOpenClaw?.id
@@ -469,6 +519,14 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
self.errorMessage = decodedErrorMessage
self.details = decodedDetails
self.isError = decodedIsError
+ self.provenance = decodedProvenance
+ self.historyMarker = decodedOpenClaw?.kind.map {
+ OpenClawChatHistoryMarker(
+ kind: $0,
+ id: decodedOpenClaw?.id,
+ tokensBefore: decodedOpenClaw?.tokensBefore,
+ tokensAfter: decodedOpenClaw?.tokensAfter)
+ }
let decodedContent: [OpenClawChatMessageContent] = if let decoded = try? container.decode(
[OpenClawChatMessageContent].self,
@@ -564,14 +622,18 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
- if self.transcriptMessageID != nil || self.isTruncated {
+ if self.transcriptMessageID != nil || self.isTruncated || self.historyMarker != nil {
try container.encode(
OpenClawMetadata(
- id: self.transcriptMessageID,
+ kind: self.historyMarker?.kind,
+ id: self.historyMarker?.id ?? self.transcriptMessageID,
idempotencyKey: nil,
- truncated: self.isTruncated ? true : nil),
+ truncated: self.isTruncated ? true : nil,
+ tokensBefore: self.historyMarker?.tokensBefore,
+ tokensAfter: self.historyMarker?.tokensAfter),
forKey: .openClaw)
}
+ try container.encodeIfPresent(self.provenance, forKey: .provenance)
try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey)
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
try container.encodeIfPresent(self.toolName, forKey: .toolName)
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift
index 75de226d2b9d..ad5df747b619 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift
@@ -1421,6 +1421,8 @@ extension OpenClawChatSQLiteTranscriptCache {
isError: item.isError)
},
timestamp: message.timestamp,
+ transcriptMessageID: message.transcriptMessageID,
+ isTruncated: message.isTruncated,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
@@ -1428,7 +1430,9 @@ extension OpenClawChatSQLiteTranscriptCache {
stopReason: message.stopReason,
errorMessage: message.errorMessage,
details: self.cacheableDetails(message.details),
- isError: message.isError)
+ isError: message.isError,
+ provenance: message.provenance,
+ historyMarker: message.historyMarker)
}
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift
index b9c85194d20f..cd9f733ba5e6 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift
@@ -14,11 +14,21 @@ public enum ChatTranscriptExporter {
timestampFormatter.timeZone = TimeZone(secondsFromGMT: 0)
var sections = ["# \(title)"]
- for message in messages where self.shouldExport(message) {
- let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter)
- let heading = "### \(self.displayRole(message.role)) — \(timestamp)"
- let body = self.body(for: message)
- sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n"))
+ for row in ChatTranscriptRow.build(from: messages) {
+ switch row {
+ case let .message(message) where self.shouldExport(message):
+ let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter)
+ let heading = "### \(self.displayRole(message.role)) — \(timestamp)"
+ let body = self.body(for: message)
+ sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n"))
+ case .message:
+ continue
+ case let .systemNotice(notice):
+ let timestamp = self.timestamp(notice.timestamp, formatter: timestampFormatter)
+ sections.append("### System — \(timestamp)\n\n[\(notice.label)] \(notice.body)")
+ case let .historyDivider(divider):
+ sections.append(self.dividerLine(divider))
+ }
}
return sections.joined(separator: "\n\n") + "\n"
}
@@ -104,6 +114,18 @@ public enum ChatTranscriptExporter {
return parts.joined(separator: "\n\n")
}
+ private static func dividerLine(_ divider: ChatTranscriptRow.HistoryDivider) -> String {
+ switch divider.kind {
+ case .compaction:
+ let text = [divider.label, divider.metric]
+ .compactMap(\.self)
+ .joined(separator: " · ")
+ return "[\(text)]"
+ case .reset:
+ return "[\(divider.label) — \(divider.description ?? "")]"
+ }
+ }
+
private static func visibleText(in message: OpenClawChatMessage) -> String {
ChatMessageVisibleText.visibleText(in: message)
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift
new file mode 100644
index 000000000000..baeebfd6570e
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift
@@ -0,0 +1,174 @@
+import Foundation
+
+enum ChatTranscriptRow: Hashable, Identifiable {
+ enum SystemNoticeKind: Hashable {
+ case restartRecovery
+ case gatewayRestarted
+ case generic
+ }
+
+ struct SystemNotice: Hashable {
+ let id: UUID
+ let kind: SystemNoticeKind
+ let body: String
+ let timestamp: Double?
+
+ var label: String {
+ switch self.kind {
+ case .restartRecovery:
+ String(localized: "System · restart recovery")
+ case .gatewayRestarted:
+ String(localized: "System · gateway restarted")
+ case .generic:
+ String(localized: "System")
+ }
+ }
+
+ var systemImage: String {
+ "cpu"
+ }
+ }
+
+ enum HistoryDividerKind: Hashable {
+ case compaction
+ case reset
+ }
+
+ struct HistoryDivider: Hashable {
+ let id: UUID
+ let kind: HistoryDividerKind
+ let savedTokens: Double?
+ let timestamp: Double?
+
+ var label: String {
+ switch self.kind {
+ case .compaction:
+ String(localized: "Compacted history")
+ case .reset:
+ String(localized: "Session reset")
+ }
+ }
+
+ var metric: String? {
+ guard self.kind == .compaction, let savedTokens else { return nil }
+ return String(
+ format: String(localized: "saved %@ tokens"),
+ ChatCompactTokenCountFormatter.string(savedTokens))
+ }
+
+ var description: String? {
+ switch self.kind {
+ case .compaction:
+ nil
+ case .reset:
+ String(localized: "The earlier conversation was cleared.")
+ }
+ }
+
+ var systemImage: String {
+ switch self.kind {
+ case .compaction:
+ "rectangle.compress.vertical"
+ case .reset:
+ "arrow.counterclockwise"
+ }
+ }
+ }
+
+ case message(OpenClawChatMessage)
+ case systemNotice(SystemNotice)
+ case historyDivider(HistoryDivider)
+
+ var id: UUID {
+ switch self {
+ case let .message(message): message.id
+ case let .systemNotice(notice): notice.id
+ case let .historyDivider(divider): divider.id
+ }
+ }
+
+ var startsTurn: Bool {
+ switch self {
+ case let .message(message):
+ message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
+ case .systemNotice:
+ true
+ case .historyDivider:
+ false
+ }
+ }
+
+ init?(_ message: OpenClawChatMessage) {
+ let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ if role == "system", let marker = message.historyMarker {
+ switch marker.kind {
+ case "compaction":
+ let savedTokens = Self.savedTokens(for: marker)
+ self = .historyDivider(HistoryDivider(
+ id: message.id,
+ kind: .compaction,
+ savedTokens: savedTokens,
+ timestamp: message.timestamp))
+ case "reset":
+ self = .historyDivider(HistoryDivider(
+ id: message.id,
+ kind: .reset,
+ savedTokens: nil,
+ timestamp: message.timestamp))
+ default:
+ return nil
+ }
+ return
+ }
+
+ if role == "user", message.provenance?.kind == "internal_system" {
+ let kind: SystemNoticeKind
+ let body: String
+ switch message.provenance?.sourceTool {
+ case "main_session_restart_recovery":
+ kind = .restartRecovery
+ body =
+ String(
+ localized: """
+ Turn interrupted by a gateway restart — asked the agent to resume and finish the response.
+ """)
+ case "restart-sentinel":
+ kind = .gatewayRestarted
+ body = Self.strippingSystemPrefix(from: ChatMessageVisibleText.visibleText(in: message))
+ default:
+ kind = .generic
+ body = Self.strippingSystemPrefix(from: ChatMessageVisibleText.visibleText(in: message))
+ }
+ guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
+ self = .systemNotice(SystemNotice(
+ id: message.id,
+ kind: kind,
+ body: body,
+ timestamp: message.timestamp))
+ return
+ }
+
+ self = .message(message)
+ }
+
+ static func build(from messages: [OpenClawChatMessage]) -> [Self] {
+ messages.compactMap(Self.init)
+ }
+
+ private static func savedTokens(for marker: OpenClawChatHistoryMarker) -> Double? {
+ guard let before = marker.tokensBefore,
+ before.isFinite,
+ let after = marker.tokensAfter,
+ after.isFinite,
+ before > after
+ else {
+ return nil
+ }
+ return floor(before - after)
+ }
+
+ private static func strippingSystemPrefix(from text: String) -> String {
+ let prefix = "[System] "
+ return text.hasPrefix(prefix) ? String(text.dropFirst(prefix.count)) : text
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift
index 4c4b45823a53..fdc73d8fe36f 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift
@@ -8,6 +8,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
case empty
case loading
case error
+ case systemNotices
}
let scenario: Scenario
@@ -38,6 +39,31 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
domain: "OpenClawChatPreviewTransport",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
+ case .systemNotices:
+ return OpenClawChatHistoryPayload(
+ sessionKey: sessionKey,
+ sessionId: "preview-system-notices",
+ messages: [
+ Self.systemNotice(
+ text: "[System] Resume the interrupted turn with internal recovery context.",
+ sourceTool: "main_session_restart_recovery",
+ timestamp: 1),
+ Self.systemNotice(
+ text: "[System] Gateway restarted after installing an update.",
+ sourceTool: "restart-sentinel",
+ timestamp: 2),
+ Self.historyMarker(
+ kind: "compaction",
+ id: "preview-compaction",
+ timestamp: 3,
+ tokensBefore: 48000,
+ tokensAfter: 19500),
+ Self.historyMarker(
+ kind: "reset",
+ id: "preview-reset",
+ timestamp: 4),
+ ],
+ thinkingLevel: "medium")
}
return OpenClawChatHistoryPayload(
@@ -121,7 +147,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
func requestHealth(timeoutMs _: Int) async throws -> Bool {
switch self.scenario {
- case .connected, .empty, .loading:
+ case .connected, .empty, .loading, .systemNotices:
true
case .error:
false
@@ -144,6 +170,36 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
])
}
+ private static func systemNotice(text: String, sourceTool: String, timestamp: Double) -> AnyCodable {
+ AnyCodable([
+ "role": "user",
+ "content": [["type": "text", "text": text]],
+ "timestamp": timestamp,
+ "provenance": [
+ "kind": "internal_system",
+ "sourceTool": sourceTool,
+ ],
+ ])
+ }
+
+ private static func historyMarker(
+ kind: String,
+ id: String,
+ timestamp: Double,
+ tokensBefore: Double? = nil,
+ tokensAfter: Double? = nil) -> AnyCodable
+ {
+ var marker: [String: Any] = ["kind": kind, "id": id]
+ marker["tokensBefore"] = tokensBefore
+ marker["tokensAfter"] = tokensAfter
+ return AnyCodable([
+ "role": "system",
+ "content": [],
+ "timestamp": timestamp,
+ "__openclaw": marker,
+ ])
+ }
+
private static func toolCall(
id: String,
name: String,
@@ -234,6 +290,12 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
sessionKey: "error-preview")
}
+#Preview("System notices") {
+ OpenClawChatPreview(
+ scenario: .systemNotices,
+ sessionKey: "system-notices-preview")
+}
+
#Preview("Onboarding chat") {
OpenClawChatView(
viewModel: OpenClawChatViewModel(
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
index b93f3a1a6853..7fa8c4d30c8d 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
@@ -64,7 +64,7 @@ func chatReaderScrollReleasesFollow(_ phase: ScrollPhase) -> Bool {
private enum ScrollFollowTarget: Equatable {
case latest
- case user(UUID)
+ case turn(UUID)
}
private struct ChatTurnRecapObservation: Equatable {
@@ -118,7 +118,7 @@ public struct OpenClawChatView: View {
@State private var scrollerBottomID = UUID()
@State private var scrollPosition: UUID?
@State private var hasPerformedInitialScroll = false
- @State private var lastUserMessageID: UUID?
+ @State private var lastTurnStartID: UUID?
@State private var hasNewerContentBelow = false
@State private var followTarget: ScrollFollowTarget? = .latest
@State private var isAtLiveEdge = true
@@ -380,7 +380,7 @@ public struct OpenClawChatView: View {
} action: { _, isAtLiveEdge in
self.isAtLiveEdge = isAtLiveEdge
guard self.hasPerformedInitialScroll else { return }
- if isAtLiveEdge, !self.isUserScrolling, !self.isFollowingUserTurn {
+ if isAtLiveEdge, !self.isUserScrolling, !self.isFollowingTurn {
self.followTarget = .latest
self.hasNewerContentBelow = false
}
@@ -427,7 +427,7 @@ public struct OpenClawChatView: View {
guard !isLoading, !self.hasPerformedInitialScroll else { return }
self.restoreInitialScrollPosition()
self.hasPerformedInitialScroll = true
- self.lastUserMessageID = self.latestVisibleUserMessageID
+ self.lastTurnStartID = self.latestVisibleTurnStartID
}
.onChange(of: self.viewModel.sessionKey) { _, _ in
self.speech?.stop()
@@ -436,7 +436,7 @@ public struct OpenClawChatView: View {
self.isAtLiveEdge = true
self.isUserScrolling = false
self.hasNewerContentBelow = false
- self.lastUserMessageID = nil
+ self.lastTurnStartID = nil
}
.onChange(of: self.scenePhase) { _, newValue in
if newValue == .background {
@@ -481,8 +481,17 @@ public struct OpenClawChatView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
- ForEach(self.visibleMessages) { msg in
- self.messageRow(for: msg, contextWindowTokens: contextWindowTokens)
+ ForEach(self.transcriptRows) { row in
+ switch row {
+ case let .message(message):
+ self.messageRow(for: message, contextWindowTokens: contextWindowTokens)
+ case let .systemNotice(notice):
+ ChatSystemNoticeRow(notice: notice)
+ .frame(maxWidth: .infinity)
+ case let .historyDivider(divider):
+ ChatHistoryDividerRow(divider: divider)
+ .frame(maxWidth: .infinity)
+ }
}
OpenClawQuestionCards(viewModel: self.viewModel)
@@ -681,7 +690,7 @@ public struct OpenClawChatView: View {
}
}
- private var visibleMessages: [OpenClawChatMessage] {
+ private var transcriptRows: [ChatTranscriptRow] {
let base: [OpenClawChatMessage]
if self.style == .onboarding {
guard let first = viewModel.messages.first else { return [] }
@@ -690,23 +699,22 @@ public struct OpenClawChatView: View {
} else {
base = self.viewModel.messages
}
- return self.mergeToolResults(in: base).filter(self.shouldDisplayMessage(_:))
- }
-
- private var latestVisibleUserMessageID: UUID? {
- self.visibleUserMessageIDs.last
- }
-
- private var visibleUserMessageIDs: [UUID] {
- self.visibleMessages.compactMap { message in
- message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
- ? message.id
- : nil
+ return ChatTranscriptRow.build(from: self.mergeToolResults(in: base)).filter { row in
+ guard case let .message(message) = row else { return true }
+ return self.shouldDisplayMessage(message)
}
}
- private var isFollowingUserTurn: Bool {
- if case .user = self.followTarget {
+ private var latestVisibleTurnStartID: UUID? {
+ self.visibleTurnStartIDs.last
+ }
+
+ private var visibleTurnStartIDs: [UUID] {
+ self.transcriptRows.compactMap { $0.startsTurn ? $0.id : nil }
+ }
+
+ private var isFollowingTurn: Bool {
+ if case .turn = self.followTarget {
return true
}
return false
@@ -787,7 +795,7 @@ public struct OpenClawChatView: View {
}
private var hasVisibleMessageListContent: Bool {
- if !self.visibleMessages.isEmpty {
+ if !self.transcriptRows.isEmpty {
return true
}
return self.hasVisibleTransientContent
@@ -918,13 +926,13 @@ public struct OpenClawChatView: View {
}
private func restoreInitialScrollPosition() {
- if let latestUserMessageID = latestVisibleUserMessageID {
+ if let latestTurnStartID = latestVisibleTurnStartID {
self.followTarget = nil
self.hasNewerContentBelow = chatReaderHasNewerContent(
- after: latestUserMessageID,
- visibleIDs: self.visibleMessages.map(\.id),
+ after: latestTurnStartID,
+ visibleIDs: self.transcriptRows.map(\.id),
hasTransientContent: self.hasVisibleTransientContent)
- self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor)
+ self.moveScrollPosition(to: latestTurnStartID, anchor: Layout.newTurnAnchor)
} else {
self.followTarget = .latest
self.hasNewerContentBelow = false
@@ -940,33 +948,29 @@ public struct OpenClawChatView: View {
self.viewModel.pendingToolCalls.isEmpty,
self.viewModel.streamingAssistantText == nil
{
- self.lastUserMessageID = nil
+ self.lastTurnStartID = nil
self.followTarget = .latest
self.hasNewerContentBelow = false
self.moveScrollPosition(to: self.scrollerBottomID)
return
}
- let visibleMessages = self.visibleMessages
- let visibleUserMessageIDs = visibleMessages.compactMap { message in
- message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
- ? message.id
- : nil
- }
+ let transcriptRows = self.transcriptRows
+ let visibleTurnStartIDs = transcriptRows.compactMap { $0.startsTurn ? $0.id : nil }
switch chatReaderUserTransition(
- previousID: self.lastUserMessageID,
- visibleIDs: visibleUserMessageIDs)
+ previousID: self.lastTurnStartID,
+ visibleIDs: visibleTurnStartIDs)
{
case let .removed(latestRemainingID):
- self.lastUserMessageID = latestRemainingID
- if case let .user(messageID) = followTarget,
- !visibleUserMessageIDs.contains(messageID)
+ self.lastTurnStartID = latestRemainingID
+ if case let .turn(messageID) = followTarget,
+ !visibleTurnStartIDs.contains(messageID)
{
self.followTarget = nil
self.hasNewerContentBelow = false
}
return
- case let .added(latestUserMessageID):
- self.lastUserMessageID = latestUserMessageID
+ case let .added(latestTurnStartID):
+ self.lastTurnStartID = latestTurnStartID
self.hasNewerContentBelow = false
// The anchored-question layout assumes a viewport tall enough to read the turn
// below the anchor. With the keyboard up that space is gone and the reply streams
@@ -975,8 +979,8 @@ public struct OpenClawChatView: View {
self.followTarget = .latest
self.moveScrollPosition(to: self.scrollerBottomID)
} else {
- self.followTarget = .user(latestUserMessageID)
- self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor)
+ self.followTarget = .turn(latestTurnStartID)
+ self.moveScrollPosition(to: latestTurnStartID, anchor: Layout.newTurnAnchor)
}
return
case .unchanged:
@@ -987,12 +991,12 @@ public struct OpenClawChatView: View {
case .latest:
self.hasNewerContentBelow = false
self.moveScrollPosition(to: self.scrollerBottomID)
- case let .user(messageID):
+ case let .turn(messageID):
// Reader policy stays on this turn after the one-shot scroll binding is released. Reissuing
// that target for every streaming delta can loop SwiftUI layout and starve interaction.
self.hasNewerContentBelow = chatReaderHasNewerContent(
after: messageID,
- visibleIDs: visibleMessages.map(\.id),
+ visibleIDs: transcriptRows.map(\.id),
hasTransientContent: self.hasVisibleTransientContent)
case nil:
self.hasNewerContentBelow = true
@@ -1082,7 +1086,9 @@ extension OpenClawChatView {
stopReason: last.stopReason,
errorMessage: last.errorMessage,
details: last.details,
- isError: last.isError)
+ isError: last.isError,
+ provenance: last.provenance,
+ historyMarker: last.historyMarker)
result[result.count - 1] = merged
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift
index ab1680c745a7..296a44c7fbde 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift
@@ -47,6 +47,8 @@ extension OpenClawChatViewModel {
role: message.role,
content: sanitizedContent,
timestamp: message.timestamp,
+ transcriptMessageID: message.transcriptMessageID,
+ isTruncated: message.isTruncated,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
@@ -54,7 +56,9 @@ extension OpenClawChatViewModel {
stopReason: message.stopReason,
errorMessage: message.errorMessage,
details: message.details,
- isError: message.isError)
+ isError: message.isError,
+ provenance: message.provenance,
+ historyMarker: message.historyMarker)
}
static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
@@ -174,7 +178,9 @@ extension OpenClawChatViewModel {
stopReason: incoming.stopReason,
errorMessage: incoming.errorMessage,
details: incoming.details,
- isError: incoming.isError)
+ isError: incoming.isError,
+ provenance: incoming.provenance ?? existing.provenance,
+ historyMarker: incoming.historyMarker ?? existing.historyMarker)
}
private static func preservingLocalAudioDurations(
@@ -480,6 +486,8 @@ extension OpenClawChatViewModel {
role: existing.role,
content: existing.content,
timestamp: existing.timestamp,
+ transcriptMessageID: existing.transcriptMessageID,
+ isTruncated: existing.isTruncated,
idempotencyKey: remoteKey,
toolCallId: existing.toolCallId,
toolName: existing.toolName,
@@ -487,7 +495,9 @@ extension OpenClawChatViewModel {
stopReason: existing.stopReason,
errorMessage: existing.errorMessage,
details: existing.details,
- isError: existing.isError)
+ isError: existing.isError,
+ provenance: existing.provenance,
+ historyMarker: existing.historyMarker)
}
self.replaceMessages(Self.dedupeMessages(updated))
guard let survivingIndex = self.messages.firstIndex(where: { message in
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift
index 94852b0e1135..6a57b1c556ec 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift
@@ -596,6 +596,8 @@ extension OpenClawChatViewModel {
role: message.role,
content: message.content,
timestamp: Date().timeIntervalSince1970 * 1000,
+ transcriptMessageID: message.transcriptMessageID,
+ isTruncated: message.isTruncated,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
@@ -603,7 +605,9 @@ extension OpenClawChatViewModel {
stopReason: message.stopReason,
errorMessage: message.errorMessage,
details: message.details,
- isError: message.isError)
+ isError: message.isError,
+ provenance: message.provenance,
+ historyMarker: message.historyMarker)
}
private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) {
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageDetailsPreservationTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageDetailsPreservationTests.swift
index fb96d7bbbe44..68f49ba78fab 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageDetailsPreservationTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageDetailsPreservationTests.swift
@@ -3,9 +3,9 @@ import OpenClawKit
import Testing
@testable import OpenClawChatUI
-// Tool-result diff metadata rides on `OpenClawChatMessage.details`; every
-// field-enumerating message rebuild must carry it or inline diffs silently
-// disappear after cache-warm reconciliation.
+/// Tool-result diff metadata rides on `OpenClawChatMessage.details`; every
+/// field-enumerating message rebuild must carry it or inline diffs silently
+/// disappear after cache-warm reconciliation.
@Suite("ChatMessageDetailsPreservation")
struct ChatMessageDetailsPreservationTests {
private func toolResultMessage(id: UUID = UUID()) -> OpenClawChatMessage {
@@ -26,12 +26,31 @@ struct ChatMessageDetailsPreservationTests {
details: AnyCodable(["diff": AnyCodable("+1 added\n-1 removed")]))
}
+ private func systemNoticeMessage(id: UUID = UUID()) -> OpenClawChatMessage {
+ OpenClawChatMessage(
+ id: id,
+ role: "user",
+ content: [
+ OpenClawChatMessageContent(
+ type: "text",
+ text: "[System] gateway restarted",
+ mimeType: nil,
+ fileName: nil,
+ content: nil),
+ ],
+ timestamp: 2,
+ provenance: OpenClawChatInputProvenance(
+ kind: "internal_system",
+ sourceTool: "restart-sentinel"))
+ }
+
@MainActor @Test func `decode pipeline keeps message details`() throws {
- let payloadData = try JSONEncoder().encode([self.toolResultMessage()])
+ let payloadData = try JSONEncoder().encode([self.toolResultMessage(), self.systemNoticeMessage()])
let anyMessages = try JSONDecoder().decode([AnyCodable].self, from: payloadData)
let decoded = OpenClawChatViewModel.decodeMessages(anyMessages)
#expect(decoded.first?.details != nil)
+ #expect(decoded.last?.provenance?.sourceTool == "restart-sentinel")
}
@MainActor @Test func `canonical adoption keeps incoming details`() {
@@ -42,5 +61,11 @@ struct ChatMessageDetailsPreservationTests {
#expect(adopted.id == existing.id)
#expect(adopted.details != nil)
+
+ let incomingNotice = self.systemNoticeMessage()
+ let adoptedNotice = OpenClawChatViewModel.adoptingCanonicalMessage(
+ incomingNotice,
+ over: self.systemNoticeMessage())
+ #expect(adoptedNotice.provenance == incomingNotice.provenance)
}
}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift
index 32f3c1d435b7..7c9314774280 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift
@@ -1,6 +1,6 @@
import Foundation
-@testable import OpenClawChatUI
import Testing
+@testable import OpenClawChatUI
private func textContent(_ text: String) -> OpenClawChatMessageContent {
OpenClawChatMessageContent(type: "text", text: text, mimeType: nil, fileName: nil, content: nil)
@@ -18,8 +18,7 @@ private func toolCallContent(name: String) -> OpenClawChatMessageContent {
fileName: nil,
content: nil,
id: "call-1",
- name: name
- )
+ name: name)
}
private func thinkingContent(_ thinking: String) -> OpenClawChatMessageContent {
@@ -29,8 +28,7 @@ private func thinkingContent(_ thinking: String) -> OpenClawChatMessageContent {
thinking: thinking,
mimeType: nil,
fileName: nil,
- content: nil
- )
+ content: nil)
}
@Suite("ChatMessageVisibleText")
@@ -43,8 +41,7 @@ struct ChatMessageVisibleTextTests {
toolCallContent(name: "exec"),
textContent("And a follow-up."),
],
- timestamp: 1
- )
+ timestamp: 1)
#expect(ChatMessageVisibleText.visibleText(in: message)
== "Here is the answer.\nAnd a follow-up.")
@@ -54,8 +51,7 @@ struct ChatMessageVisibleTextTests {
let message = OpenClawChatMessage(
role: "user",
content: [textContent("What is up?")],
- timestamp: 1
- )
+ timestamp: 1)
#expect(ChatMessageVisibleText.visibleText(in: message) == "What is up?")
}
@@ -64,13 +60,11 @@ struct ChatMessageVisibleTextTests {
let assistant = OpenClawChatMessage(
role: "assistant",
content: [textContent("private reasoning\nVisible **answer**")],
- timestamp: 1
- )
+ timestamp: 1)
let user = OpenClawChatMessage(
role: "user",
content: [textContent("Keep this literal tag")],
- timestamp: 1
- )
+ timestamp: 1)
#expect(ChatMessageVisibleText.copyText(in: assistant) == "Visible **answer**")
#expect(ChatMessageVisibleText.copyText(in: user) == "Keep this literal tag")
@@ -84,8 +78,7 @@ struct ChatMessageVisibleTextTests {
textContent("Here is the answer."),
toolCallContent(name: "read"),
],
- timestamp: 1
- )
+ timestamp: 1)
#expect(ChatMessageVisibleText.displayText(in: message, includeThinking: false)
== "Here is the answer.")
@@ -104,13 +97,11 @@ struct ChatMessageVisibleTextTests {
typedTextContent("tool_result", "tool payload"),
typedTextContent(nil, "legacy visible"),
],
- timestamp: 1
- )
+ timestamp: 1)
#expect(
ChatMessageVisibleText.displayText(in: message, includeThinking: false) ==
- "visible output\nvisible input\nlegacy visible"
- )
+ "visible output\nvisible input\nlegacy visible")
}
@Test func `responses text visibility follows the chat role contract`() {
@@ -126,25 +117,43 @@ struct ChatMessageVisibleTextTests {
for entry in cases {
#expect(
ChatMessageVisibleText.isVisibleContentType(entry.type, role: entry.role)
- == entry.expected
- )
+ == entry.expected)
}
}
- @Test func `history decode retains transcript identity and truncation signals`() throws {
+ @Test func `history decode retains transcript metadata and system row facts`() throws {
let metadata = try JSONDecoder().decode(
OpenClawChatMessage.self,
- from: Data(#"{"role":"assistant","content":"short","__openclaw":{"id":"msg-1","truncated":true}}"#.utf8)
- )
+ from: Data(#"{"role":"assistant","content":"short","__openclaw":{"id":"msg-1","truncated":true}}"#.utf8))
let marker = try JSONDecoder().decode(
OpenClawChatMessage.self,
- from: Data(#"{"role":"assistant","content":"short\n...(truncated)...","__openclaw":{"id":"msg-2"}}"#.utf8)
- )
+ from: Data(#"{"role":"assistant","content":"short\n...(truncated)...","__openclaw":{"id":"msg-2"}}"#.utf8))
+ let notice = try JSONDecoder().decode(
+ OpenClawChatMessage.self,
+ from: Data(
+ #"{"role":"user","content":"[System] resumed","provenance":{"kind":"internal_system","originSessionId":"origin-1","sourceSessionKey":"agent:main","sourceChannel":"system","sourceTool":"restart-sentinel"}}"#
+ .utf8))
+ let historyMarker = try JSONDecoder().decode(
+ OpenClawChatMessage.self,
+ from: Data(
+ #"{"role":"system","content":[],"__openclaw":{"kind":"compaction","id":"compact-1","tokensBefore":22000,"tokensAfter":9000}}"#
+ .utf8))
#expect(metadata.transcriptMessageID == "msg-1")
#expect(metadata.isTruncated)
#expect(marker.transcriptMessageID == "msg-2")
#expect(marker.isTruncated)
+ #expect(notice.provenance == OpenClawChatInputProvenance(
+ kind: "internal_system",
+ originSessionId: "origin-1",
+ sourceSessionKey: "agent:main",
+ sourceChannel: "system",
+ sourceTool: "restart-sentinel"))
+ #expect(historyMarker.historyMarker == OpenClawChatHistoryMarker(
+ kind: "compaction",
+ id: "compact-1",
+ tokensBefore: 22000,
+ tokensAfter: 9000))
}
@Test func `transcript metadata survives message coding round trip`() throws {
@@ -153,16 +162,31 @@ struct ChatMessageVisibleTextTests {
content: [textContent("short\n...(truncated)...")],
timestamp: 1,
transcriptMessageID: "msg-round-trip",
- isTruncated: true
- )
+ isTruncated: true)
let decoded = try JSONDecoder().decode(
OpenClawChatMessage.self,
- from: JSONEncoder().encode(original)
- )
+ from: JSONEncoder().encode(original))
+ let systemRow = OpenClawChatMessage(
+ role: "system",
+ content: [],
+ timestamp: 2,
+ provenance: OpenClawChatInputProvenance(
+ kind: "internal_system",
+ sourceTool: "restart-sentinel"),
+ historyMarker: OpenClawChatHistoryMarker(
+ kind: "compaction",
+ id: "compact-round-trip",
+ tokensBefore: 10000,
+ tokensAfter: 4000))
+ let decodedSystemRow = try JSONDecoder().decode(
+ OpenClawChatMessage.self,
+ from: JSONEncoder().encode(systemRow))
#expect(decoded.transcriptMessageID == "msg-round-trip")
#expect(decoded.isTruncated)
+ #expect(decodedSystemRow.provenance == systemRow.provenance)
+ #expect(decodedSystemRow.historyMarker == systemRow.historyMarker)
}
@Test func `legacy trace mapping sets both independent display options`() {
@@ -175,23 +199,19 @@ struct ChatMessageVisibleTextTests {
let toolOnly = OpenClawChatMessage(
role: "assistant",
content: [toolCallContent(name: "exec")],
- timestamp: 1
- )
+ timestamp: 1)
let blank = OpenClawChatMessage(
role: "assistant",
content: [textContent(" ")],
- timestamp: 1
- )
+ timestamp: 1)
let spoken = OpenClawChatMessage(
role: "assistant",
content: [textContent("Say this")],
- timestamp: 1
- )
+ timestamp: 1)
let thinkingOnly = OpenClawChatMessage(
role: "assistant",
content: [textContent("Do not speak this")],
- timestamp: 1
- )
+ timestamp: 1)
#expect(!ChatMessageVisibleText.hasVisibleText(in: toolOnly))
#expect(!ChatMessageVisibleText.hasVisibleText(in: blank))
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatStreamReplayTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatStreamReplayTests.swift
index 7a155b9aca97..656896101b09 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatStreamReplayTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatStreamReplayTests.swift
@@ -1,5 +1,6 @@
import Foundation
import OpenClawKit
+import OpenClawProtocol
import Testing
@testable import OpenClawChatUI
@@ -309,7 +310,7 @@ extension OpenClawChatViewModel {
// MARK: - Markdown shapes fixture
-// Extended-delimiter literal keeps the fenced Swift interpolation inert.
+/// Extended-delimiter literal keeps the fenced Swift interpolation inert.
private let markdownShapesFixture = #"""
# Release Notes
@@ -344,6 +345,37 @@ Closing paragraph with unicode — dashes, émojis 🦀🚀, and a trailing line
/// `session.message` rows, duplicate delivery, out-of-order arrival, and reconnect
/// convergence. Tracking: #100196.
struct ChatStreamReplayTests {
+ @Test func `live session message marker produces a visible transcript row`() async throws {
+ let harness = try await StreamReplayHarness.bootstrapped()
+ let frame = EventFrame(
+ type: "event",
+ event: "session.message",
+ payload: AnyCodable([
+ "sessionKey": "main",
+ "messageId": "live-reset",
+ "message": [
+ "role": "system",
+ "content": [],
+ "timestamp": 1,
+ "__openclaw": ["kind": "reset", "id": "live-reset"],
+ ],
+ ]))
+ let event = try #require(OpenClawChatGatewayPayloadCodec.event(from: frame))
+
+ harness.transport.emit(event)
+ try await harness.converge("live reset marker appended") { vm in
+ vm.messages.contains { $0.historyMarker?.kind == "reset" }
+ }
+
+ let rows = await MainActor.run { ChatTranscriptRow.build(from: harness.vm.messages) }
+ guard let last = rows.last, case let .historyDivider(divider) = last else {
+ Issue.record("Expected the live reset marker to produce a divider")
+ return
+ }
+ #expect(divider.label == "Session reset")
+ #expect(divider.description == "The earlier conversation was cleared.")
+ }
+
@Test func `clean streaming run converges losslessly to durable rows`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let finalText = "Hello, world!"
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift
index 92b2ff2dcdfd..232b4befefab 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift
@@ -495,7 +495,15 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S
details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")])),
],
timestamp: 1,
- details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")]))
+ details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")]),
+ provenance: OpenClawChatInputProvenance(
+ kind: "internal_system",
+ sourceTool: "restart-sentinel"),
+ historyMarker: OpenClawChatHistoryMarker(
+ kind: "compaction",
+ id: "compact-cache",
+ tokensBefore: 12000,
+ tokensAfter: 7000))
let cached = try #require(OpenClawChatSQLiteTranscriptCache.cacheableMessages([message]).first)
#expect(cached.content[0].content == nil)
@@ -503,6 +511,8 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S
#expect(Set(cached.content[0].arguments?.dictionaryValue?.keys.map(\.self) ?? []) == ["input"])
#expect(cached.content[0].arguments?.dictionaryValue?["input"]?.stringValue?.utf16.count == 64000)
#expect(Set(cached.details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"])
+ #expect(cached.provenance == message.provenance)
+ #expect(cached.historyMarker == message.historyMarker)
}
@Test func `gateway removal deletes only that gateways cache and state`() async throws {
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift
index be9d558beac7..197b70d167da 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift
@@ -1,8 +1,59 @@
+import Foundation
import Testing
@testable import OpenClawChatUI
@Suite("ChatTranscriptExporter")
struct ChatTranscriptExporterTests {
+ @Test func `exports system transcript rows without leaking internal prompts`() throws {
+ let wire = Data(#"""
+ [
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "[System] Resume the interrupted turn with private context."}],
+ "timestamp": 0,
+ "provenance": {"kind": "internal_system", "sourceTool": "main_session_restart_recovery"}
+ },
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "[System] Gateway restarted after an update."}],
+ "timestamp": 500,
+ "provenance": {"kind": "internal_system", "sourceTool": "restart-sentinel"}
+ },
+ {
+ "role": "system",
+ "content": [],
+ "timestamp": 1000,
+ "__openclaw": {"kind": "compaction", "id": "compact-1", "tokensBefore": 25000, "tokensAfter": 12500}
+ },
+ {
+ "role": "system",
+ "content": [],
+ "timestamp": 2000,
+ "__openclaw": {"kind": "reset", "id": "reset-1"}
+ },
+ {
+ "role": "system",
+ "content": [{"type": "text", "text": "Unknown marker body"}],
+ "timestamp": 3000,
+ "__openclaw": {"kind": "future-marker", "id": "future-1"}
+ }
+ ]
+ """#.utf8)
+ let messages = try JSONDecoder().decode([OpenClawChatMessage].self, from: wire)
+
+ let markdown = ChatTranscriptExporter.markdown(
+ sessionTitle: "System events",
+ sessionKey: "agent:main",
+ messages: messages)
+
+ #expect(!markdown.contains("private context"))
+ #expect(markdown.contains("System · restart recovery"))
+ #expect(markdown.contains("[System · gateway restarted] Gateway restarted after an update."))
+ #expect(markdown.contains("[Compacted history · saved 12.5k tokens]"))
+ #expect(markdown.contains("[Session reset — The earlier conversation was cleared.]"))
+ #expect(!markdown.contains("Unknown marker body"))
+ }
+
@Test func `formats visible messages and attachments`() {
let messages = [
self.message(role: "system", text: "Hidden setup", timestamp: 0),
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptRowTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptRowTests.swift
new file mode 100644
index 000000000000..f83b353ace24
--- /dev/null
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptRowTests.swift
@@ -0,0 +1,145 @@
+import Testing
+@testable import OpenClawChatUI
+
+@Suite("ChatTranscriptRow")
+struct ChatTranscriptRowTests {
+ private enum Input: Sendable {
+ case notice(sourceTool: String?, text: String)
+ case marker(kind: String, tokensBefore: Double? = nil, tokensAfter: Double? = nil)
+ }
+
+ private enum Expected: Sendable {
+ case notice(label: String, body: String)
+ case divider(label: String, metric: String?, description: String?)
+ case hidden
+ }
+
+ private struct Case: Sendable {
+ let name: String
+ let input: Input
+ let expected: Expected
+ }
+
+ @Test(arguments: [
+ Case(
+ name: "restart recovery hides producer prompt",
+ input: .notice(
+ sourceTool: "main_session_restart_recovery",
+ text: "[System] private recovery prompt"),
+ expected: .notice(
+ label: "System · restart recovery",
+ body: "Turn interrupted by a gateway restart — asked the agent to resume and finish the response.")),
+ Case(
+ name: "restart sentinel keeps producer text",
+ input: .notice(
+ sourceTool: "restart-sentinel",
+ text: "[System] Gateway restarted after an update."),
+ expected: .notice(
+ label: "System · gateway restarted",
+ body: "Gateway restarted after an update.")),
+ Case(
+ name: "other source tool is generic without fuzzy matching",
+ input: .notice(
+ sourceTool: "Restart-Sentinel",
+ text: "[System] Doctor repaired the gateway."),
+ expected: .notice(
+ label: "System",
+ body: "Doctor repaired the gateway.")),
+ Case(
+ name: "compaction reports finite token savings",
+ input: .marker(kind: "compaction", tokensBefore: 22750, tokensAfter: 9200),
+ expected: .divider(
+ label: "Compacted history",
+ metric: "saved 13.6k tokens",
+ description: nil)),
+ Case(
+ name: "reset explains cleared history",
+ input: .marker(kind: "reset"),
+ expected: .divider(
+ label: "Session reset",
+ metric: nil,
+ description: "The earlier conversation was cleared.")),
+ Case(
+ name: "unknown marker is hidden without fuzzy matching",
+ input: .marker(kind: "Compaction", tokensBefore: 10000, tokensAfter: 1000),
+ expected: .hidden),
+ ])
+ private func `classifies control UI system row contracts`(testCase: Case) {
+ let message = switch testCase.input {
+ case let .notice(sourceTool, text):
+ OpenClawChatMessage(
+ role: "user",
+ content: [OpenClawChatMessageContent(
+ type: "text",
+ text: text,
+ mimeType: nil,
+ fileName: nil,
+ content: nil)],
+ timestamp: 1,
+ provenance: OpenClawChatInputProvenance(
+ kind: "internal_system",
+ sourceTool: sourceTool))
+ case let .marker(kind, tokensBefore, tokensAfter):
+ OpenClawChatMessage(
+ role: "system",
+ content: [],
+ timestamp: 1,
+ historyMarker: OpenClawChatHistoryMarker(
+ kind: kind,
+ id: "marker-1",
+ tokensBefore: tokensBefore,
+ tokensAfter: tokensAfter))
+ }
+
+ let row = ChatTranscriptRow(message)
+ switch (testCase.expected, row) {
+ case (.hidden, nil):
+ break
+ case let (.notice(expectedLabel, expectedBody), .systemNotice(notice)):
+ #expect(notice.label == expectedLabel)
+ #expect(notice.body == expectedBody)
+ case let (.divider(expectedLabel, expectedMetric, expectedDescription), .historyDivider(divider)):
+ #expect(divider.label == expectedLabel)
+ #expect(divider.metric == expectedMetric)
+ #expect(divider.description == expectedDescription)
+ default:
+ Issue.record("Unexpected row classification for \(testCase.name)")
+ }
+ }
+
+ @Test(arguments: [
+ (nil, nil),
+ (10000, nil),
+ (10000, 10000),
+ (10000, 12000),
+ (Double.infinity, 1000),
+ ])
+ func `compaction omits invalid token metrics`(tokensBefore: Double?, tokensAfter: Double?) throws {
+ let message = OpenClawChatMessage(
+ role: "system",
+ content: [],
+ timestamp: 1,
+ historyMarker: OpenClawChatHistoryMarker(
+ kind: "compaction",
+ tokensBefore: tokensBefore,
+ tokensAfter: tokensAfter))
+
+ guard case let .historyDivider(divider) = try #require(ChatTranscriptRow(message)) else {
+ Issue.record("Expected a compaction divider")
+ return
+ }
+ #expect(divider.metric == nil)
+ }
+
+ @Test(arguments: [
+ (0, "0"),
+ (999, "999"),
+ (1000, "1k"),
+ (214_500, "214.5k"),
+ (999_950, "1M"),
+ (1_050_000, "1.1M"),
+ ])
+ func `compact token counts mirror the control UI`(tokens: Double, expected: String) {
+ #expect(ChatCompactTokenCountFormatter.string(tokens) == expected)
+ }
+}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
index 858d750162f8..ae4fc0cc8a7f 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
@@ -11486,6 +11486,50 @@ struct ChatViewModelTests {
#expect(sanitized == "Hello?")
}
+ @Test func `history system facts survive sanitation and produce visible rows`() async throws {
+ let history = historyPayloadWithoutRunState(
+ messages: [
+ AnyCodable([
+ "role": "user",
+ "content": [["type": "text", "text": "[System] Gateway restarted cleanly."]],
+ "timestamp": 1,
+ "provenance": [
+ "kind": "internal_system",
+ "sourceTool": "restart-sentinel",
+ ],
+ ]),
+ AnyCodable([
+ "role": "system",
+ "content": [],
+ "timestamp": 2,
+ "__openclaw": [
+ "kind": "compaction",
+ "id": "compact-history",
+ "tokensBefore": 20000,
+ "tokensAfter": 8000,
+ ],
+ ]),
+ ])
+ let transport = TestChatTransport(historyResponses: [history])
+ let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+
+ await MainActor.run { vm.load() }
+ try await waitUntil("system history loaded") { await MainActor.run { vm.messages.count == 2 } }
+
+ let rows = await MainActor.run { ChatTranscriptRow.build(from: vm.messages) }
+ #expect(rows.count == 2)
+ guard let first = rows.first, case let .systemNotice(notice) = first else {
+ Issue.record("Expected a restart notice")
+ return
+ }
+ #expect(notice.body == "Gateway restarted cleanly.")
+ guard let last = rows.last, case let .historyDivider(divider) = last else {
+ Issue.record("Expected a compaction divider")
+ return
+ }
+ #expect(divider.metric == "saved 12k tokens")
+ }
+
@Test func `abort requests do not clear pending until aborted event`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)