From cb51cf7d3ee3410df7fa24d1bf355ac6bb19e035 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 14:47:19 -0700 Subject: [PATCH] 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 --- apps/.i18n/native-source.json | 136 ++++++++++---- .../ChatCompactTokenCountFormatter.swift | 23 +++ .../OpenClawChatUI/ChatContextUsage.swift | 27 +-- .../OpenClawChatUI/ChatMessageViews.swift | 85 +++++++++ .../Sources/OpenClawChatUI/ChatModels.swift | 70 ++++++- .../OpenClawChatUI/ChatTranscriptCache.swift | 6 +- .../ChatTranscriptExporter.swift | 32 +++- .../OpenClawChatUI/ChatTranscriptRows.swift | 174 ++++++++++++++++++ .../OpenClawChatUI/ChatView+Previews.swift | 64 ++++++- .../Sources/OpenClawChatUI/ChatView.swift | 98 +++++----- .../ChatViewModel+HistoryReconciliation.swift | 16 +- .../ChatViewModel+TransportEvents.swift | 6 +- .../ChatMessageDetailsPreservationTests.swift | 33 +++- .../ChatMessageVisibleTextTests.swift | 96 ++++++---- .../ChatStreamReplayTests.swift | 34 +++- .../ChatTranscriptCacheStoreTests.swift | 12 +- .../ChatTranscriptExporterTests.swift | 51 +++++ .../ChatTranscriptRowTests.swift | 145 +++++++++++++++ .../OpenClawKitTests/ChatViewModelTests.swift | 44 +++++ 19 files changed, 988 insertions(+), 164 deletions(-) create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCompactTokenCountFormatter.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptRowTests.swift 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)