Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift
Peter Steinberger 4bfaccafd6 feat(chat): replay the active plan snapshot to reconnecting clients (#109966)
* feat(chat): replay the active plan snapshot to reconnecting clients

Plan checklists were live-event-only: a client that connected or
refreshed mid-run saw nothing until the next update_plan call, and iOS
adoptRun actively wiped retained plan state on reconnect. The gateway
now records the latest normalized plan snapshot per run beside the
existing chat text buffers and replays it through the established
chat.history/chat.startup inFlightRun recovery contract (runId > plan >
text under the payload budget; a budget-dropped plan is sent as an
explicit empty snapshot so clients never preserve a stale checklist).

Web, iOS, and Android seed and reconcile the snapshot through one shared
contract riding each client's existing inFlightRun adoption gate: present
plan replaces, absent plan preserves (legacy gateway), a different or
terminated run clears, and stale history responses cannot clobber a newer
live plan. Verified live gap: openclaw/openclaw#108675 comment 5001475066.

* fix(ios): extract run-snapshot adoption below the file-length lint ceiling

ChatViewModel.swift crossed SwiftLint's 1500-line limit; the in-flight
snapshot adoption and plan reconciliation move to a focused extension.
Also refresh the native i18n inventory for shifted line anchors.

* fixup: regenerate i18n anchors and test formatting after rebase

* ci: retrigger after wedged queued runs

* fix(ui): use Object.hasOwn for the plan field presence probe

* fix(ios): annotate the plan-snapshot fixture init for periphery
2026-07-17 17:10:07 +01:00

695 lines
23 KiB
Swift

import Foundation
import OpenClawKit
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
#if canImport(AppKit)
import AppKit
public typealias OpenClawPlatformImage = NSImage
#elseif canImport(UIKit)
import UIKit
public typealias OpenClawPlatformImage = UIImage
#endif
public enum OpenClawChatCommandFilter: String, CaseIterable, Sendable {
case all = "All"
case commands = "Commands"
case skills = "Skills"
}
public struct OpenClawChatCommandChoice: Identifiable, Hashable, Sendable {
public enum Source: String, Sendable {
case command
case skill
case plugin
case unknown
}
public let id: String
public let name: String
public let textAliases: [String]
public let description: String
public let source: Source
public let acceptsArgs: Bool
public init(
id: String,
name: String,
textAliases: [String],
description: String,
source: Source,
acceptsArgs: Bool)
{
self.id = id
self.name = name
self.textAliases = textAliases
self.description = description
self.source = source
self.acceptsArgs = acceptsArgs
}
public var preferredInvocation: String {
self.textAliases.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("/") }
?? "/\(self.name)"
}
public var displayInvocation: String {
self.preferredInvocation.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
public struct OpenClawChatUsageCost: Codable, Hashable, Sendable {
public let input: Double?
public let output: Double?
public let cacheRead: Double?
public let cacheWrite: Double?
public let total: Double?
}
public struct OpenClawChatUsage: Codable, Hashable, Sendable {
public let input: Int?
public let output: Int?
public let cacheRead: Int?
public let cacheWrite: Int?
public let cost: OpenClawChatUsageCost?
public let total: Int?
enum CodingKeys: String, CodingKey {
case input
case output
case cacheRead
case cacheWrite
case cost
case total
case totalTokens
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.input = try container.decodeIfPresent(Int.self, forKey: .input)
self.output = try container.decodeIfPresent(Int.self, forKey: .output)
self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead)
self.cacheWrite = try container.decodeIfPresent(Int.self, forKey: .cacheWrite)
self.cost = try container.decodeIfPresent(OpenClawChatUsageCost.self, forKey: .cost)
self.total =
try container.decodeIfPresent(Int.self, forKey: .total) ??
container.decodeIfPresent(Int.self, forKey: .totalTokens)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.input, forKey: .input)
try container.encodeIfPresent(self.output, forKey: .output)
try container.encodeIfPresent(self.cacheRead, forKey: .cacheRead)
try container.encodeIfPresent(self.cacheWrite, forKey: .cacheWrite)
try container.encodeIfPresent(self.cost, forKey: .cost)
try container.encodeIfPresent(self.total, forKey: .total)
}
}
public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
public let type: String?
public let text: String?
public let thinking: String?
public let thinkingSignature: String?
public let mimeType: String?
public let fileName: String?
public let durationSeconds: Double?
public let content: AnyCodable?
public let preview: OpenClawChatCanvasPreview?
// Tool-call fields (when `type == "toolCall"` or similar)
public let id: String?
public let name: String?
public let arguments: AnyCodable?
public init(
type: String?,
text: String?,
thinking: String? = nil,
thinkingSignature: String? = nil,
mimeType: String?,
fileName: String?,
durationSeconds: Double? = nil,
content: AnyCodable?,
preview: OpenClawChatCanvasPreview? = nil,
id: String? = nil,
name: String? = nil,
arguments: AnyCodable? = nil)
{
self.type = type
self.text = text
self.thinking = thinking
self.thinkingSignature = thinkingSignature
self.mimeType = mimeType
self.fileName = fileName
self.durationSeconds = durationSeconds
self.content = content
self.preview = preview
self.id = id
self.name = name
self.arguments = arguments
}
enum CodingKeys: String, CodingKey {
case type
case text
case thinking
case thinkingSignature
case mimeType
case fileName
case durationSeconds
case content
case preview
case id
case name
case arguments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decodeIfPresent(String.self, forKey: .type)
self.text = try container.decodeIfPresent(String.self, forKey: .text)
self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking)
self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature)
self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName)
self.durationSeconds = try container.decodeIfPresent(Double.self, forKey: .durationSeconds)
self.id = try container.decodeIfPresent(String.self, forKey: .id)
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments)
self.preview = try container.decodeIfPresent(OpenClawChatCanvasPreview.self, forKey: .preview)
if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) {
self.content = any
} else if let str = try container.decodeIfPresent(String.self, forKey: .content) {
self.content = AnyCodable(str)
} else {
self.content = nil
}
}
}
public struct OpenClawChatCanvasPreview: Codable, Hashable, Sendable {
public let kind: String?
public let surface: String?
public let render: String?
public let title: String?
public let preferredHeight: Double?
public let url: String?
public let viewId: String?
public let sandbox: String?
public var inlineWidgetPath: String? {
guard self.kind == "canvas",
self.surface == "assistant_message",
self.render == "url",
self.sandbox == "scripts" || self.sandbox == "strict",
let url = self.url?.trimmingCharacters(in: .whitespacesAndNewlines),
OpenClawChatWidgetURLResolver.supportsTarget(url)
else { return nil }
return url
}
public var inlineWidgetHeight: Double {
min(max(self.preferredHeight ?? 320, 160), 1200)
}
}
public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
private struct OpenClawMetadata: Codable {
let idempotencyKey: String?
}
public var id: UUID = .init()
public let role: String
public let content: [OpenClawChatMessageContent]
public let timestamp: Double?
public let idempotencyKey: String?
public let toolCallId: String?
public let toolName: String?
public let usage: OpenClawChatUsage?
public let stopReason: String?
public let errorMessage: String?
enum CodingKeys: String, CodingKey {
case role
case content
case timestamp
case idempotencyKey
case openClaw = "__openclaw"
case toolCallId
case tool_call_id
case toolName
case tool_name
case usage
case stopReason
case errorMessage
case mediaPath = "MediaPath"
case mediaPaths = "MediaPaths"
case mediaType = "MediaType"
case mediaTypes = "MediaTypes"
}
public init(
id: UUID = .init(),
role: String,
content: [OpenClawChatMessageContent],
timestamp: Double?,
idempotencyKey: String? = nil,
toolCallId: String? = nil,
toolName: String? = nil,
usage: OpenClawChatUsage? = nil,
stopReason: String? = nil,
errorMessage: String? = nil)
{
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
self.idempotencyKey = idempotencyKey
self.toolCallId = toolCallId
self.toolName = toolName
self.usage = usage
self.stopReason = stopReason
self.errorMessage = errorMessage
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedRole = try container.decode(String.self, forKey: .role)
let decodedTimestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
let decodedOpenClaw = try container.decodeIfPresent(OpenClawMetadata.self, forKey: .openClaw)
let decodedIdempotencyKey = try decodedOpenClaw?.idempotencyKey ??
container.decodeIfPresent(String.self, forKey: .idempotencyKey)
let decodedToolCallId =
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
container.decodeIfPresent(String.self, forKey: .tool_call_id)
let decodedToolName =
try container.decodeIfPresent(String.self, forKey: .toolName) ??
container.decodeIfPresent(String.self, forKey: .tool_name)
let decodedUsage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage)
let decodedStopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
let decodedErrorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage)
self.role = decodedRole
self.timestamp = decodedTimestamp
self.idempotencyKey = decodedIdempotencyKey
self.toolCallId = decodedToolCallId
self.toolName = decodedToolName
self.usage = decodedUsage
self.stopReason = decodedStopReason
self.errorMessage = decodedErrorMessage
let decodedContent: [OpenClawChatMessageContent] = if let decoded = try? container.decode(
[OpenClawChatMessageContent].self,
forKey: .content)
{
decoded
} else if let text = try? container.decode(String.self, forKey: .content) {
// Some session log formats store `content` as a plain string.
[
OpenClawChatMessageContent(
type: "text",
text: text,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: nil,
name: nil,
arguments: nil),
]
} else {
[]
}
let mediaPaths =
(try? container.decode([String].self, forKey: .mediaPaths))
?? (try? container.decode(String.self, forKey: .mediaPath)).map { [$0] }
?? []
let mediaTypes =
(try? container.decode([String].self, forKey: .mediaTypes))
?? (try? container.decode(String.self, forKey: .mediaType)).map { [$0] }
?? []
let alreadyContainsAudio = decodedContent.contains { content in
content.mimeType?.lowercased().hasPrefix("audio/") == true
}
let audioAttachments: [OpenClawChatMessageContent] = alreadyContainsAudio ? [] : mediaPaths
.enumerated()
.compactMap { index, mediaPath in
guard mediaTypes.indices.contains(index) else { return nil }
let mimeType = mediaTypes[index].trimmingCharacters(in: .whitespacesAndNewlines)
guard mimeType.lowercased().hasPrefix("audio/") else { return nil }
return OpenClawChatMessageContent(
type: "file",
text: nil,
mimeType: mimeType,
fileName: (mediaPath as NSString).lastPathComponent,
content: nil)
}
self.content = decodedContent + audioAttachments
}
static func displayText(
contentText: String,
role: String,
stopReason: String?,
errorMessage: String?) -> String
{
let text = contentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let errorText = Self.errorDisplayText(
role: role,
stopReason: stopReason,
errorMessage: errorMessage)
else {
return text
}
if text.isEmpty || text == Self.streamErrorFallbackText {
return errorText
}
return text
}
static func errorDisplayText(role: String, stopReason: String?, errorMessage: String?) -> String? {
let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let normalizedStopReason = stopReason?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalizedRole == "assistant",
normalizedStopReason == "error",
let text = errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
return text
}
private static let streamErrorFallbackText = "[assistant turn failed before producing content]"
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey)
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
try container.encodeIfPresent(self.toolName, forKey: .toolName)
try container.encodeIfPresent(self.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encodeIfPresent(self.errorMessage, forKey: .errorMessage)
try container.encode(self.content, forKey: .content)
}
}
public struct OpenClawChatInFlightRun: Codable, Sendable {
public let runId: String
public let text: String
public let plan: OpenClawChatPlanSnapshot?
// periphery:ignore - package tests construct history fixtures; app consumers decode this payload.
public init(runId: String, text: String, plan: OpenClawChatPlanSnapshot? = nil) {
self.runId = runId
self.text = text
self.plan = plan
}
}
public struct OpenClawChatPlanSnapshot: Codable, Sendable {
public let steps: [OpenClawChatPlanStep]
public let explanation: String?
// periphery:ignore - package tests construct history fixtures; app consumers decode this payload.
public init(steps: [OpenClawChatPlanStep], explanation: String? = nil) {
self.steps = steps
self.explanation = explanation
}
}
public struct OpenClawChatSessionInfo: Codable, Sendable {
public let hasActiveRun: Bool?
public let activeRunIds: [String]?
// periphery:ignore - package tests construct history fixtures; app consumers decode this payload.
public init(hasActiveRun: Bool?, activeRunIds: [String]? = nil) {
self.hasActiveRun = hasActiveRun
self.activeRunIds = activeRunIds
}
}
public struct OpenClawChatHistoryPayload: Codable, Sendable {
public let sessionKey: String
public let sessionId: String?
public let messages: [AnyCodable]?
public let thinkingLevel: String?
public let sessionInfo: OpenClawChatSessionInfo?
public let inFlightRun: OpenClawChatInFlightRun?
public init(
sessionKey: String,
sessionId: String?,
messages: [AnyCodable]?,
thinkingLevel: String?,
sessionInfo: OpenClawChatSessionInfo? = nil,
inFlightRun: OpenClawChatInFlightRun? = nil)
{
self.sessionKey = sessionKey
self.sessionId = sessionId
self.messages = messages
self.thinkingLevel = thinkingLevel
self.sessionInfo = sessionInfo
self.inFlightRun = inFlightRun
}
}
public struct OpenClawSessionPreviewItem: Codable, Hashable, Sendable {
public let role: String
public let text: String
}
public struct OpenClawSessionPreviewEntry: Codable, Sendable {
public let key: String
public let status: String
public let items: [OpenClawSessionPreviewItem]
}
public struct OpenClawSessionsPreviewPayload: Codable, Sendable {
public let ts: Int
public let previews: [OpenClawSessionPreviewEntry]
public init(ts: Int, previews: [OpenClawSessionPreviewEntry]) {
self.ts = ts
self.previews = previews
}
}
public struct OpenClawChatSendResponse: Codable, Sendable {
public let runId: String
public let status: String
}
public struct OpenClawChatCreateSessionResponse: Codable, Sendable {
public let ok: Bool?
public let key: String
public let sessionId: String?
}
public struct OpenClawChatEventPayload: Codable, Sendable {
public let runId: String?
public let sessionKey: String?
public let agentId: String?
public let state: String?
public let message: AnyCodable?
public let errorMessage: String?
// periphery:ignore - package tests construct transport events; app consumers decode them.
public init(
runId: String?,
sessionKey: String?,
agentId: String? = nil,
state: String?,
message: AnyCodable?,
errorMessage: String?)
{
self.runId = runId
self.sessionKey = sessionKey
self.agentId = agentId
self.state = state
self.message = message
self.errorMessage = errorMessage
}
}
public struct OpenClawSessionMessageEventPayload: Codable, Sendable {
public let sessionKey: String?
public let agentId: String?
public let message: OpenClawChatMessage?
public let messageId: String?
public let messageSeq: Int?
// periphery:ignore - package tests construct transport events; app consumers decode them.
public init(
sessionKey: String?,
agentId: String? = nil,
message: OpenClawChatMessage?,
messageId: String?,
messageSeq: Int?)
{
self.sessionKey = sessionKey
self.agentId = agentId
self.message = message
self.messageId = messageId
self.messageSeq = messageSeq
}
}
public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
public var id: String {
"\(self.runId)-\(self.seq ?? -1)"
}
public let runId: String
public let seq: Int?
public let stream: String
public let ts: Int?
public let data: [String: AnyCodable]
}
public struct OpenClawChatPlanStep: Codable, Hashable, Sendable {
public enum Status: String, Codable, Hashable, Sendable {
case pending
case inProgress = "in_progress"
case completed
}
public let step: String
public let status: Status
public init(step: String, status: Status) {
self.step = step
self.status = status
}
static func parseSteps(_ value: AnyCodable?) -> [Self] {
guard let value else { return [] }
let rawItems: [Any]
switch value.value {
case let items as [AnyCodable]:
rawItems = items.map(\.value)
case let items as [Any]:
rawItems = items
case let items as NSArray:
rawItems = items.map(\.self)
default:
return []
}
var hasInProgressStep = false
return rawItems.compactMap { rawItem in
guard let step = Self.parseStep(rawItem) else { return nil }
if step.status == .inProgress {
guard !hasInProgressStep else { return nil }
hasInProgressStep = true
}
return step
}
}
private static func parseStep(_ rawValue: Any) -> Self? {
let value = (rawValue as? AnyCodable)?.value ?? rawValue
if let legacyStep = value as? String {
return self.makeStep(text: legacyStep, status: .pending)
}
let fields: [String: Any]
switch value {
case let dictionary as [String: AnyCodable]:
fields = dictionary.mapValues(\.value)
case let dictionary as [String: String]:
fields = dictionary
case let dictionary as [String: Any]:
fields = dictionary
case let dictionary as NSDictionary:
fields = dictionary.reduce(into: [:]) { result, entry in
guard let key = entry.key as? String else { return }
result[key] = (entry.value as? AnyCodable)?.value ?? entry.value
}
default:
return nil
}
guard let text = fields["step"] as? String,
let rawStatus = fields["status"] as? String,
let status = Status(rawValue: rawStatus)
else {
return nil
}
return self.makeStep(text: text, status: status)
}
private static func makeStep(text: String, status: Status) -> Self? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return Self(step: trimmed, status: status)
}
}
public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
public var id: String {
self.toolCallId
}
public let toolCallId: String
public let name: String
public let args: AnyCodable?
public let startedAt: Double?
public let isError: Bool?
}
public struct OpenClawGatewayHealthOK: Codable, Sendable {
public let ok: Bool?
}
public struct OpenClawPendingAttachment: Identifiable {
public let id = UUID()
public let url: URL?
public let data: Data
public let fileName: String
public let mimeType: String
public let type: String
public let preview: OpenClawPlatformImage?
public let durationSeconds: Double?
public init(
url: URL?,
data: Data,
fileName: String,
mimeType: String,
type: String = "file",
preview: OpenClawPlatformImage?,
durationSeconds: Double? = nil)
{
self.url = url
self.data = data
self.fileName = fileName
self.mimeType = mimeType
self.type = type
self.preview = preview
self.durationSeconds = durationSeconds
}
}
public struct OpenClawChatAttachmentPayload: Codable, Sendable, Hashable {
public let type: String
public let mimeType: String
public let fileName: String
public let content: String
public init(type: String, mimeType: String, fileName: String, content: String) {
self.type = type
self.mimeType = mimeType
self.fileName = fileName
self.content = content
}
}