feat(apps): Apple chat adopts the system-notice family (#122255)

* feat(apps): adopt the system-notice family in Apple chat (OpenClawChatUI)

Decode internal_system provenance and __openclaw history markers, classify
once into message/notice/divider rows (web-parity kinds), render minimal
hairline notice/divider SwiftUI rows, preserve metadata through history
reconciliation, live transport, and the transcript cache, and align the
transcript exporter with visible classification instead of leaking raw
[System] prompts.

* fix(apps): satisfy OpenClawKit periphery gate

Remove the dead visibleMessages projection (rows path replaced its
consumers) and annotate the provenance test-fixture initializer with the
repo-standard periphery:ignore rationale.

* fix(apps): refresh native i18n inventory after periphery cleanup
This commit is contained in:
Peter Steinberger
2026-08-11 14:47:19 -07:00
committed by GitHub
parent edb7a1692e
commit cb51cf7d3e
19 changed files with 988 additions and 164 deletions
+100 -36
View File
@@ -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",
@@ -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
}
}
@@ -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)
@@ -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
@@ -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)
@@ -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)
}
}
@@ -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)
}
@@ -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
}
}
@@ -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(
@@ -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
}
@@ -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
@@ -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) {
@@ -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)
}
}
@@ -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 <final>up</final>?")],
timestamp: 1
)
timestamp: 1)
#expect(ChatMessageVisibleText.visibleText(in: message) == "What is <final>up</final>?")
}
@@ -64,13 +60,11 @@ struct ChatMessageVisibleTextTests {
let assistant = OpenClawChatMessage(
role: "assistant",
content: [textContent("<think>private reasoning</think>\nVisible **answer**")],
timestamp: 1
)
timestamp: 1)
let user = OpenClawChatMessage(
role: "user",
content: [textContent("Keep <think>this literal tag</think>")],
timestamp: 1
)
timestamp: 1)
#expect(ChatMessageVisibleText.copyText(in: assistant) == "Visible **answer**")
#expect(ChatMessageVisibleText.copyText(in: user) == "Keep <think>this literal tag</think>")
@@ -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("<think>Do not speak this</think>")],
timestamp: 1
)
timestamp: 1)
#expect(!ChatMessageVisibleText.hasVisibleText(in: toolOnly))
#expect(!ChatMessageVisibleText.hasVisibleText(in: blank))
@@ -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!"
@@ -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 {
@@ -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),
@@ -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)
}
}
@@ -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)