feat(macos): render system chat option cards (#110584)

* feat(macos): render system chat option cards

* fix(macos): localize question card controls

* test(macos): stabilize main actor attachment tests
This commit is contained in:
Peter Steinberger 2026-07-18 13:23:59 +01:00 committed by GitHub
parent bc8d0da092
commit 28069db63a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 716 additions and 30 deletions

View file

@ -32243,7 +32243,7 @@
},
{
"kind": "ui-localized-call",
"line": 10,
"line": 11,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "Wake up, my friend!",
"surface": "apple",
@ -32251,7 +32251,7 @@
},
{
"kind": "conditional-branch",
"line": 129,
"line": 229,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "<redacted secret>",
"surface": "apple",
@ -32259,7 +32259,7 @@
},
{
"kind": "conditional-branch",
"line": 228,
"line": 288,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "OpenClaw was interrupted. Restart to try again.",
"surface": "apple",
@ -32267,7 +32267,7 @@
},
{
"kind": "conditional-branch",
"line": 229,
"line": 289,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "The Gateway connection changed. Restart OpenClaw to reconnect.",
"surface": "apple",
@ -32275,7 +32275,7 @@
},
{
"kind": "ui-call",
"line": 253,
"line": 331,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "OpenClaw is working…",
"surface": "apple",
@ -32283,7 +32283,7 @@
},
{
"kind": "ui-call",
"line": 278,
"line": 356,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "Restart",
"surface": "apple",
@ -32291,7 +32291,7 @@
},
{
"kind": "ui-call",
"line": 289,
"line": 367,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "Enter secret…",
"surface": "apple",
@ -32299,12 +32299,28 @@
},
{
"kind": "ui-call",
"line": 291,
"line": 369,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "Reply to OpenClaw… (yes sets everything up)",
"surface": "apple",
"id": "native.apple.35a542956f4f5497"
},
{
"kind": "ui-call",
"line": 409,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "Skip for now",
"surface": "apple",
"id": "native.apple.134b9ae1d8a543df"
},
{
"kind": "ui-call",
"line": 444,
"path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift",
"source": "Recommended",
"surface": "apple",
"id": "native.apple.5435391fa338f227"
},
{
"kind": "ui-call",
"line": 10,

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "رد على OpenClaw… (تؤدي الإجابة بنعم إلى إعداد كل شيء)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "تخطي الآن"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "موصى به"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "OpenClaw antworten… („yes“ richtet alles ein)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Vorerst überspringen"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Empfohlen"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Responde a OpenClaw… («sí» lo configura todo)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Omitir por ahora"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Recomendado"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "به OpenClaw پاسخ دهید… (با «بله» همه‌چیز راه‌اندازی می‌شود)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "فعلاً رد شود"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "پیشنهادشده"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Répondre à OpenClaw… (« yes » configure tout)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Ignorer pour linstant"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Recommandé"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "OpenClaw को जवाब दें… (हाँ कहने पर सब कुछ सेट हो जाएगा)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "अभी के लिए छोड़ें"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "अनुशंसित"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Balas OpenClaw… (yes menyiapkan semuanya)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Lewati untuk sekarang"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Direkomendasikan"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Rispondi a OpenClaw… (sì configura tutto)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Salta per ora"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Consigliato"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "OpenClawに返信…yesですべてセットアップされます"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "今はスキップ"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "おすすめ"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "OpenClaw에 답장… (yes를 입력하면 모든 설정이 완료됩니다)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "지금은 건너뛰기"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "권장"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Antwoord aan OpenClaw… (met ja wordt alles ingesteld)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Voor nu overslaan"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Aanbevolen"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Odpowiedz OpenClaw… („yes” skonfiguruje wszystko)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Pomiń na razie"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Zalecane"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Responda ao OpenClaw… (sim configura tudo)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Pular por enquanto"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Recomendado"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Ответьте OpenClaw… (yes настроит всё)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Пропустить пока"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Рекомендуется"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Svara OpenClaw… (ja konfigurerar allt)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Hoppa över tills vidare"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Rekommenderas"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "ตอบกลับ OpenClaw… (พิมพ์ yes เพื่อตั้งค่าทุกอย่าง)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "ข้ามไปก่อน"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "แนะนำ"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "OpenClaw'a yanıt verin… (evet, her şeyi ayarlar)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Şimdilik atla"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Önerilen"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Відповісти OpenClaw… (так налаштує все)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Пропустити зараз"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Рекомендовано"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "Trả lời OpenClaw… (chọn có để thiết lập mọi thứ)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "Bỏ qua lúc này"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "Được đề xuất"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "回复 OpenClaw…回复 yes 即可完成所有设置)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "暂时跳过"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "推荐"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -20193,6 +20193,16 @@
"source": "Reply to OpenClaw… (yes sets everything up)",
"translated": "回覆 OpenClaw…輸入 yes 即可完成所有設定)"
},
{
"id": "native.apple.134b9ae1d8a543df",
"source": "Skip for now",
"translated": "暫時略過"
},
{
"id": "native.apple.5435391fa338f227",
"source": "Recommended",
"translated": "建議"
},
{
"id": "native.apple.78d1ad73762a7ce8",
"source": "Connect your AI",

View file

@ -1,5 +1,6 @@
import Foundation
import Observation
import OpenClawKit
import SwiftUI
enum SystemAgentDraft: String, Decodable {
@ -60,12 +61,21 @@ final class SystemAgentOnboardingChatModel {
let id = UUID()
let role: Role
let text: String
let question: SystemAgentChatQuestion?
init(role: Role, text: String, question: SystemAgentChatQuestion? = nil) {
self.role = role
self.text = text
self.question = question
}
}
private(set) var messages: [Message] = []
private(set) var isSending = false
private(set) var errorMessage: String?
private(set) var expectsSensitiveReply = false
private(set) var dismissedQuestionMessageIDs: Set<UUID> = []
private(set) var retiredQuestionMessageIDs: Set<UUID> = []
var input = ""
/// Set when OpenClaw hands off to the normal agent ("talk to agent").
var onAgentHandoff: ((SystemAgentDraft?) -> Void)?
@ -100,6 +110,7 @@ final class SystemAgentOnboardingChatModel {
let action: String
let sensitive: Bool?
let agentDraft: SystemAgentDraft?
let question: AnyCodable?
}
func startIfNeeded() async {
@ -118,23 +129,42 @@ final class SystemAgentOnboardingChatModel {
@discardableResult
func send() -> Task<Void, Never>? {
let text = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard let generation = self.requestGeneration,
!text.isEmpty,
!self.isSending,
self.errorMessage == nil
return self.send(message: text)
}
@discardableResult
func answerQuestion(messageID: UUID, optionLabel: String) -> Task<Void, Never>? {
guard let message = self.messages.first(where: { $0.id == messageID }),
let question = message.question,
let option = question.options.first(where: { $0.label == optionLabel }),
self.canAnswerQuestion(message)
else { return nil }
self.input = ""
self.messages.append(Message(
role: .user,
text: self.expectsSensitiveReply ? "<redacted secret>" : text))
let task = Task { [weak self] in
guard let self else { return }
await self.requestReply(message: text, generation: generation)
}
self.requestTask = task
// The typed Gateway contract separates the visible label from its canonical reply.
// Keep the transcript human-readable while returning the machine-facing value.
return self.send(message: option.reply ?? option.label, displayText: option.label)
}
@discardableResult
func skipQuestion(messageID: UUID) -> Task<Void, Never>? {
guard let message = self.messages.first(where: { $0.id == messageID }),
self.canAnswerQuestion(message),
let task = self.send(message: "Skip for now", displayText: "Skip for now")
else { return nil }
self.dismissedQuestionMessageIDs.insert(messageID)
return task
}
func isQuestionVisible(_ message: Message) -> Bool {
message.question != nil && !self.dismissedQuestionMessageIDs.contains(message.id)
}
func canAnswerQuestion(_ message: Message) -> Bool {
self.isQuestionVisible(message) &&
!self.retiredQuestionMessageIDs.contains(message.id) &&
!self.isSending &&
self.errorMessage == nil
}
@discardableResult
func restartAfterError() -> Task<Void, Never>? {
guard let previousGeneration = self.requestGeneration else { return nil }
@ -145,6 +175,8 @@ final class SystemAgentOnboardingChatModel {
self.route = nil
self.started = true
self.messages.removeAll()
self.dismissedQuestionMessageIDs.removeAll()
self.retiredQuestionMessageIDs.removeAll()
self.input = ""
self.expectsSensitiveReply = false
let task = Task { [weak self] in
@ -184,6 +216,31 @@ final class SystemAgentOnboardingChatModel {
return route
}
private func send(message: String, displayText: String? = nil) -> Task<Void, Never>? {
guard let generation = self.requestGeneration,
!message.isEmpty,
!self.isSending,
self.errorMessage == nil
else { return nil }
self.retireQuestions()
self.input = ""
self.messages.append(Message(
role: .user,
text: displayText ?? (self.expectsSensitiveReply ? "<redacted secret>" : message)))
let task = Task { [weak self] in
guard let self else { return }
await self.requestReply(message: message, generation: generation)
}
self.requestTask = task
return task
}
private func retireQuestions() {
for message in self.messages where message.question != nil {
self.retiredQuestionMessageIDs.insert(message.id)
}
}
private func requestReply(message: String?, generation: UInt64) async {
guard self.isCurrentRequest(generation) else { return }
self.isSending = true
@ -215,7 +272,10 @@ final class SystemAgentOnboardingChatModel {
let result = try JSONDecoder().decode(ChatResult.self, from: data)
guard self.isCurrentRequest(generation) else { return }
self.expectsSensitiveReply = result.sensitive == true
self.messages.append(Message(role: .assistant, text: result.reply))
self.messages.append(Message(
role: .assistant,
text: result.reply,
question: SystemAgentChatQuestion.parse(result.question?.dictionaryValue)))
self.onReplyReceived?()
if result.action == "open-agent" {
self.onAgentHandoff?(result.agentDraft)
@ -243,8 +303,26 @@ struct SystemAgentOnboardingChatView: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(self.model.messages) { message in
SystemAgentChatBubble(message: message)
.id(message.id)
VStack(alignment: .leading, spacing: 8) {
SystemAgentChatBubble(message: message)
if let question = message.question,
self.model.isQuestionVisible(message)
{
SystemAgentChatQuestionCard(
question: question,
isEnabled: self.model.canAnswerQuestion(message),
onSelect: { option in
self.model.answerQuestion(
messageID: message.id,
optionLabel: option.label)
},
onSkip: {
self.model.skipQuestion(messageID: message.id)
})
.padding(.trailing, 40)
}
}
.id(message.id)
}
if self.model.isSending {
HStack(spacing: 8) {
@ -312,6 +390,82 @@ struct SystemAgentOnboardingChatView: View {
}
}
private struct SystemAgentChatQuestionCard: View {
let question: SystemAgentChatQuestion
let isEnabled: Bool
let onSelect: (SystemAgentChatQuestion.Option) -> Void
let onSkip: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(self.question.header.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.accentColor)
Text(self.question.question)
.font(.callout.weight(.semibold))
ForEach(self.question.options, id: \.label) { option in
self.optionButton(option)
}
Button("Skip for now", action: self.onSkip)
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
.disabled(!self.isEnabled)
}
.padding(12)
.frame(maxWidth: 460, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(.secondary.opacity(0.2)))
.accessibilityElement(children: .contain)
.accessibilityLabel(self.question.question)
}
private func optionButton(_ option: SystemAgentChatQuestion.Option) -> some View {
Button {
self.onSelect(option)
} label: {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(option.label)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
if let description = option.description {
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 8)
if option.recommended {
Text("Recommended")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(option.recommended
? Color.accentColor.opacity(0.12)
: Color(NSColor.windowBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(option.recommended
? Color.accentColor.opacity(0.55)
: Color.secondary.opacity(0.16)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!self.isEnabled)
}
}
private struct SystemAgentChatBubble: View {
let message: SystemAgentOnboardingChatModel.Message

View file

@ -27,6 +27,18 @@ private actor SystemAgentSessionRecorder {
}
}
private actor SystemAgentMessageRecorder {
private var messages: [String] = []
func record(_ message: String) {
self.messages.append(message)
}
func snapshot() -> [String] {
self.messages
}
}
private actor SystemAgentMethodRecorder {
private var methods: [String] = []
@ -88,6 +100,20 @@ private func systemAgentRequestMethod(from message: URLSessionWebSocketTask.Mess
return object["method"] as? String
}
private func systemAgentChatMessage(from message: URLSessionWebSocketTask.Message) -> String? {
let data: Data? = switch message {
case let .data(data): data
case let .string(string): string.data(using: .utf8)
@unknown default: nil
}
guard let data,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
object["method"] as? String == "openclaw.chat",
let params = object["params"] as? [String: Any]
else { return nil }
return params["message"] as? String
}
private func respondToSystemAgentHealth(
task: GatewayTestWebSocketTask,
id: String,
@ -101,9 +127,11 @@ private func respondToSystemAgentHealth(
private func systemAgentResponse(
id: String,
action: String = "none",
agentDraft: String? = nil) -> Data
agentDraft: String? = nil,
questionJSON: String? = nil) -> Data
{
let agentDraftField = agentDraft.map { ",\n \"agentDraft\": \"\($0)\"" } ?? ""
let questionField = questionJSON.map { ",\n \"question\": \($0)" } ?? ""
return Data(
"""
{
@ -114,7 +142,7 @@ private func systemAgentResponse(
"sessionId": "test-session",
"reply": "ready",
"action": "\(action)",
"sensitive": false\(agentDraftField)
"sensitive": false\(agentDraftField)\(questionField)
}
}
""".utf8)
@ -482,6 +510,114 @@ struct OnboardingSystemAgentChatTests {
#expect(session.latestTask()?.snapshotSendCount() == 2)
}
@Test func `typed question sends reply while transcript shows label`() async throws {
let recordedMessages = SystemAgentMessageRecorder()
let questionJSON =
"""
{"id":"next","header":"Next step","question":"What now?","options":[
{"label":"Talk to my agent","reply":"talk to agent","recommended":true},
{"label":"Connect WhatsApp","reply":"connect whatsapp","description":"Chat there."}
],"isOther":true}
"""
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
if let message = systemAgentChatMessage(from: message) {
await recordedMessages.record(message)
task.emitReceiveSuccess(.data(systemAgentResponse(id: id)))
} else {
task.emitReceiveSuccess(.data(systemAgentResponse(
id: id,
questionJSON: questionJSON)))
}
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let chat = SystemAgentOnboardingChatModel(gateway: gateway)
await chat.startIfNeeded()
let assistant = try #require(chat.messages.first)
let question = try #require(assistant.question)
#expect(question.options.first?.recommended == true)
let task = try #require(chat.answerQuestion(
messageID: assistant.id,
optionLabel: "Connect WhatsApp"))
await task.value
#expect(await recordedMessages.snapshot() == ["connect whatsapp"])
#expect(chat.messages.map(\.text) == ["ready", "Connect WhatsApp", "ready"])
#expect(!chat.canAnswerQuestion(assistant))
}
@Test func `typed question skip sends fixed reply and dismisses cards`() async throws {
let recordedMessages = SystemAgentMessageRecorder()
let questionJSON =
#"{"id":"next","header":"Next step","question":"What now?","options":[{"label":"A"},{"label":"B"}]}"#
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
if let message = systemAgentChatMessage(from: message) {
await recordedMessages.record(message)
task.emitReceiveSuccess(.data(systemAgentResponse(id: id)))
} else {
task.emitReceiveSuccess(.data(systemAgentResponse(
id: id,
questionJSON: questionJSON)))
}
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let chat = SystemAgentOnboardingChatModel(gateway: gateway)
await chat.startIfNeeded()
let assistant = try #require(chat.messages.first)
let task = try #require(chat.skipQuestion(messageID: assistant.id))
await task.value
#expect(await recordedMessages.snapshot() == ["Skip for now"])
#expect(chat.messages.map(\.text) == ["ready", "Skip for now", "ready"])
#expect(!chat.isQuestionVisible(assistant))
}
@Test(arguments: [
#"{"id":"dupes","header":"Next step","question":"What now?","options":[{"label":"Same"},{"label":"same"}]}"#,
#""invalid""#,
#"[]"#,
])
func `malformed typed question keeps prose reply only`(questionJSON: String) async throws {
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
task.emitReceiveSuccess(.data(systemAgentResponse(
id: id,
questionJSON: questionJSON)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let chat = SystemAgentOnboardingChatModel(gateway: gateway)
await chat.startIfNeeded()
#expect(chat.messages.map(\.text) == ["ready"])
#expect(chat.messages.first?.question == nil)
}
@Test func `agent handoff carries the hatch draft intent`() async throws {
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in

View file

@ -0,0 +1,63 @@
import Foundation
public struct SystemAgentChatQuestion: Equatable, Sendable {
public struct Option: Equatable, Sendable {
public let label: String
public let description: String?
public let recommended: Bool
public let reply: String?
}
public let id: String
public let header: String
public let question: String
public let options: [Option]
public let isOther: Bool
/// Card-capable clients validate the open payload before turning values into actions.
/// Invalid questions stay prose-only instead of exposing partial or ambiguous choices.
public static func parse(_ value: [String: AnyCodable]?) -> Self? {
guard let value,
let id = nonEmptyString(value["id"]),
let header = nonEmptyString(value["header"]),
let question = nonEmptyString(value["question"]),
let rawOptions = value["options"]?.arrayValue,
(2...4).contains(rawOptions.count)
else { return nil }
var labels = Set<String>()
var recommendedCount = 0
var options: [Option] = []
options.reserveCapacity(rawOptions.count)
for rawOption in rawOptions {
guard let option = rawOption.dictionaryValue,
let label = Self.nonEmptyString(option["label"]),
labels.insert(label.lowercased()).inserted
else { return nil }
let recommended = option["recommended"]?.boolValue == true
recommendedCount += recommended ? 1 : 0
guard recommendedCount <= 1 else { return nil }
options.append(Option(
label: label,
description: Self.nonEmptyString(option["description"]),
recommended: recommended,
reply: Self.nonEmptyString(option["reply"])))
}
return Self(
id: id,
header: header,
question: question,
options: options,
isOther: value["isOther"]?.boolValue == true)
}
private static func nonEmptyString(_ value: AnyCodable?) -> String? {
guard let string = value?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty
else { return nil }
return string
}
}

View file

@ -297,7 +297,7 @@ final class ChatViewModelAttachmentTests: XCTestCase {
}
@MainActor
func testPartialIdentitySyncPreservesTheOtherDeferredComponent() {
func testPartialIdentitySyncPreservesTheOtherDeferredComponent() async {
let oldContract = "per-sender|main|main"
let newContract = "per-sender|work-main|main"
let contractViewModel = OpenClawChatViewModel(
@ -342,7 +342,7 @@ final class ChatViewModelAttachmentTests: XCTestCase {
}
@MainActor
func testAttachmentStagingPinsSessionAndIdentityUntilItFinishes() {
func testAttachmentStagingPinsSessionAndIdentityUntilItFinishes() async {
let viewModel = OpenClawChatViewModel(
sessionKey: "main",
transport: AttachmentProcessingTransport(),
@ -369,7 +369,7 @@ final class ChatViewModelAttachmentTests: XCTestCase {
}
@MainActor
func testRecordingPinsSessionAndIdentityUntilItEnds() {
func testRecordingPinsSessionAndIdentityUntilItEnds() async {
let ownerActivity = AttachmentOwnerActivity()
let viewModel = OpenClawChatViewModel(
sessionKey: "main",

View file

@ -0,0 +1,104 @@
import Foundation
import OpenClawKit
import OpenClawProtocol
import Testing
struct SystemAgentChatQuestionTests {
@Test
func `valid gateway question keeps display and reply fields`() throws {
let decoded = try Self.parse(
"""
{
"id": " onboarding-next-step ",
"header": " Next step ",
"question": " What would you like to do first? ",
"options": [
{
"label": " Talk to my agent ",
"reply": " talk to agent ",
"recommended": true
},
{
"label": "Connect WhatsApp",
"reply": "connect whatsapp",
"description": " Chat there. "
}
],
"isOther": true
}
""")
let parsed = try #require(decoded)
#expect(parsed.id == "onboarding-next-step")
#expect(parsed.header == "Next step")
#expect(parsed.question == "What would you like to do first?")
#expect(parsed.isOther)
#expect(parsed.options.count == 2)
#expect(parsed.options[0].label == "Talk to my agent")
#expect(parsed.options[0].reply == "talk to agent")
#expect(parsed.options[0].recommended)
#expect(parsed.options[1].description == "Chat there.")
#expect(!parsed.options[1].recommended)
}
@Test
func `optional malformed metadata is ignored`() throws {
let decoded = try Self.parse(
"""
{
"id": "channel",
"header": "Channel",
"question": "Which channel?",
"options": [
{"label": "WhatsApp", "description": 42, "reply": " ", "recommended": "yes"},
{"label": "Telegram"}
],
"isOther": "yes"
}
""")
let parsed = try #require(decoded)
#expect(parsed.options[0].description == nil)
#expect(parsed.options[0].reply == nil)
#expect(!parsed.options[0].recommended)
#expect(!parsed.isOther)
}
@Test
func `malformed gateway questions degrade to nil`() throws {
let malformedQuestions = [
#"{"id":"one","header":"H","question":"Q","options":[{"label":"Only"}]}"#,
"""
{"id":"five","header":"H","question":"Q","options":\
[{"label":"A"},{"label":"B"},{"label":"C"},{"label":"D"},{"label":"E"}]}
""",
#"{"id":"dupes","header":"H","question":"Q","options":[{"label":"Same"},{"label":"same"}]}"#,
"""
{"id":"recommended","header":"H","question":"Q","options":\
[{"label":"A","recommended":true},{"label":"B","recommended":true}]}
""",
#"{"id":"blank","header":"H","question":"Q","options":[{"label":"A"},{"label":" "}]}"#,
#"{"id":"missing-header","question":"Q","options":[{"label":"A"},{"label":"B"}]}"#,
#"{"id":"bad-option","header":"H","question":"Q","options":[{"label":"A"},"B"]}"#,
]
for json in malformedQuestions {
#expect(try Self.parse(json) == nil)
}
}
private static func parse(_ questionJSON: String) throws -> SystemAgentChatQuestion? {
let result = try JSONDecoder().decode(
SystemAgentChatResult.self,
from: Data(
"""
{
"sessionId": "test-session",
"reply": "The prose reply stands alone.",
"action": "none",
"question": \(questionJSON)
}
""".utf8))
return SystemAgentChatQuestion.parse(result.question)
}
}

View file

@ -103,7 +103,10 @@ the credential with the same live test before storing its auth profile. Next
remains locked until one backend has passed, so the first agent chat cannot
start without working inference. After that live check passes, OpenClaw becomes
available to help configure the remaining workspace, Gateway, channels, and
other optional features; it is also available later under Settings → OpenClaw.
other optional features. When OpenClaw offers a short list of choices, the app
shows native option cards; choosing one sends the selection, and **Skip for
now** always leaves the choice optional. OpenClaw is also available later under
Settings → OpenClaw.
</Step>
<Step title="Import memories (shown when detected)">
For a local Gateway, onboarding checks the Mac for memories from supported AI