Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift
T
Peter Steinberger 4b46a14a54 feat(apple): show live subagent activity in chat (#121815)
* feat(apple): show live subagent activity in chat

* chore(apple): refresh native localization inventory
2026-08-10 21:50:13 -07:00

656 lines
28 KiB
Swift

import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawChatUI
struct ChatGatewayRequestTests {
@Test func `session observation requests encode global subscription and actual visibility`() {
let subscribe = OpenClawChatGatewayRequests.subscribeSessions()
let visible = OpenClawChatGatewayRequests.setSessionObserverVisibility(true)
let hidden = OpenClawChatGatewayRequests.setSessionObserverVisibility(false)
let longerSubscription = OpenClawChatGatewayRequests.subscribeSessions(timeoutMs: 12000)
let longerVisibility = OpenClawChatGatewayRequests.setSessionObserverVisibility(
true,
timeoutMs: 12000)
#expect(subscribe.method == "sessions.subscribe")
#expect(subscribe.params.isEmpty)
#expect(subscribe.timeoutMs == 10000)
#expect(visible.method == "sessions.observer.visibility")
#expect(visible.params["visible"]?.value as? Bool == true)
#expect(visible.timeoutMs == 10000)
#expect(hidden.method == "sessions.observer.visibility")
#expect(hidden.params["visible"]?.value as? Bool == false)
#expect(hidden.timeoutMs == 10000)
#expect(longerSubscription.timeoutMs == 12000)
#expect(longerVisibility.timeoutMs == 12000)
}
@Test func `session targets share normalization while preserving platform routing policy`() {
#expect(OpenClawChatSessionTarget.resolve(
" Matrix:Channel:Room ",
selectedAgentID: " Reviewer ",
policy: .scopeBareKeysToSelectedAgent) == .init(
sessionKey: "agent:reviewer:Matrix:Channel:Room",
agentID: nil))
#expect(OpenClawChatSessionTarget.resolve(
" main ",
selectedAgentID: " Reviewer ",
policy: .preserveBareKeys) == .init(sessionKey: "main", agentID: nil))
#expect(OpenClawChatSessionTarget.resolve(
" GLOBAL ",
selectedAgentID: " Reviewer ",
policy: .preserveBareKeys) == .init(sessionKey: "GLOBAL", agentID: "reviewer"))
#expect(OpenClawChatSessionTarget.resolve(
"agent:ops:main",
selectedAgentID: "reviewer",
policy: .scopeBareKeysToSelectedAgent) == .init(
sessionKey: "agent:ops:main",
agentID: nil))
#expect(OpenClawChatSessionTarget.resolve(
"agent::main",
selectedAgentID: "reviewer",
policy: .scopeBareKeysToSelectedAgent) == .init(
sessionKey: "agent::main",
agentID: nil))
}
@Test func `list sessions request normalizes optional filters`() {
let request = OpenClawChatGatewayRequests.sessionsList(
limit: 12,
search: " incident ",
archived: true)
#expect(request.method == "sessions.list")
#expect(request.timeoutMs == 15000)
#expect(request.params["includeGlobal"]?.value as? Bool == true)
#expect(request.params["includeUnknown"]?.value as? Bool == false)
#expect(request.params["limit"]?.value as? Int == 12)
#expect(request.params["search"]?.value as? String == "incident")
#expect(request.params["archived"]?.value as? Bool == true)
}
@Test func `child session request encodes focused pagination filters`() {
let request = OpenClawChatGatewayRequests.sessionsList(
limit: 10000,
search: nil,
archived: false,
includeGlobal: false,
spawnedBy: " agent:main:parent ",
offset: 10000,
configuredAgentsOnly: true)
#expect(request.params["includeGlobal"]?.value as? Bool == false)
#expect(request.params["spawnedBy"]?.value as? String == "agent:main:parent")
#expect(request.params["offset"]?.value as? Int == 10000)
#expect(request.params["configuredAgentsOnly"]?.value as? Bool == true)
}
@Test func `session patch request preserves explicit null clearing`() {
let request = OpenClawChatGatewayRequests.patchSession(
sessionKey: "global",
agentID: "reviewer",
label: .some(nil),
category: .some(nil),
pinned: true,
archived: nil,
unread: false)
#expect(request.method == "sessions.patch")
#expect(request.params["key"]?.value as? String == "global")
#expect(request.params["agentId"]?.value as? String == "reviewer")
#expect(request.params["label"]?.value is NSNull)
#expect(request.params["category"]?.value is NSNull)
#expect(request.params["pinned"]?.value as? Bool == true)
#expect(request.params["unread"]?.value as? Bool == false)
#expect(request.params["archived"] == nil)
}
@Test func `settings patch request encodes default model as null`() {
let request = OpenClawChatGatewayRequests.patchSessionSettings(
sessionKey: "agent:main:main",
agentID: nil,
model: .some(nil))
#expect(request.params["model"]?.value is NSNull)
#expect(request.params["agentId"] == nil)
}
@Test func `settings patch request encodes model thinking and verbosity atomically`() {
let request = OpenClawChatGatewayRequests.patchSessionSettings(
sessionKey: "global",
agentID: "reviewer",
model: .some("openai/gpt-5.6-sol"),
thinkingLevel: .some("ultra"),
verboseLevel: .some("full"))
#expect(request.method == "sessions.patch")
#expect(request.params["key"]?.value as? String == "global")
#expect(request.params["agentId"]?.value as? String == "reviewer")
#expect(request.params["model"]?.value as? String == "openai/gpt-5.6-sol")
#expect(request.params["thinkingLevel"]?.value as? String == "ultra")
#expect(request.params["verboseLevel"]?.value as? String == "full")
}
@Test func `settings patch request encodes fast values and explicit resets`() {
let reset = OpenClawChatGatewayRequests.patchSessionSettings(
sessionKey: "main",
agentID: nil,
thinkingLevel: .some(nil),
fastMode: .some(nil),
verboseLevel: .some(nil))
let automatic = OpenClawChatGatewayRequests.patchSessionSettings(
sessionKey: "main",
agentID: nil,
fastMode: .some(.automatic))
#expect(reset.params["thinkingLevel"]?.value is NSNull)
#expect(reset.params["fastMode"]?.value is NSNull)
#expect(reset.params["verboseLevel"]?.value is NSNull)
#expect(automatic.params["fastMode"]?.value as? String == "auto")
}
@Test func `fork and create requests preserve routing identity`() {
let fork = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: "agent:reviewer:telegram:group:1",
agentID: "reviewer")
#expect(fork.method == "sessions.create")
#expect(fork.params["parentSessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
#expect(fork.params["agentId"]?.value as? String == "reviewer")
#expect(fork.params["fork"]?.value as? Bool == true)
let create = OpenClawChatGatewayRequests.createSession(
key: "agent:reviewer:new",
agentID: "reviewer",
label: nil,
parentSessionKey: "global",
worktree: true,
worktreeBaseRef: " origin/release ")
#expect(create.params["key"]?.value as? String == "agent:reviewer:new")
#expect(create.params["agentId"]?.value as? String == "reviewer")
#expect(create.params["parentSessionKey"]?.value as? String == "global")
#expect(create.params["worktree"]?.value as? Bool == true)
#expect(create.params["worktreeBaseRef"]?.value as? String == "origin/release")
}
@Test func `message rewind and fork requests preserve routing identity`() {
let rewind = OpenClawChatGatewayRequests.rewindSession(
sessionKey: "agent:reviewer:telegram:group:1",
agentID: " reviewer ",
entryId: " message-42 ")
let fork = OpenClawChatGatewayRequests.forkAtMessage(
sessionKey: "global",
agentID: nil,
entryId: "message-43")
#expect(rewind.method == "sessions.rewind")
#expect(rewind.timeoutMs == 15000)
#expect(rewind.params["sessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
#expect(rewind.params["agentId"]?.value as? String == "reviewer")
#expect(rewind.params["entryId"]?.value as? String == "message-42")
#expect(rewind.params["key"] == nil)
#expect(fork.method == "sessions.fork")
#expect(fork.timeoutMs == 15000)
#expect(fork.params["sessionKey"]?.value as? String == "global")
#expect(fork.params["agentId"] == nil)
#expect(fork.params["entryId"]?.value as? String == "message-43")
#expect(fork.params["key"] == nil)
}
@Test func `branch list and switch requests preserve routing identity`() {
let list = OpenClawChatGatewayRequests.listSessionBranches(
sessionKey: "agent:reviewer:telegram:group:1",
agentID: " reviewer ")
let switchBranch = OpenClawChatGatewayRequests.switchSessionBranch(
sessionKey: "global",
agentID: nil,
leafEntryId: " leaf-42 ")
#expect(list.method == "sessions.branches.list")
#expect(list.timeoutMs == 15000)
#expect(list.params["sessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
#expect(list.params["agentId"]?.value as? String == "reviewer")
#expect(list.params["key"] == nil)
#expect(switchBranch.method == "sessions.branches.switch")
#expect(switchBranch.timeoutMs == 15000)
#expect(switchBranch.params["sessionKey"]?.value as? String == "global")
#expect(switchBranch.params["agentId"] == nil)
#expect(switchBranch.params["leafEntryId"]?.value as? String == "leaf-42")
#expect(switchBranch.params["key"] == nil)
}
@Test func `session group requests encode exact gateway contracts`() {
let list = OpenClawChatGatewayRequests.sessionGroupsList()
let put = OpenClawChatGatewayRequests.sessionGroupsPut(names: ["Work", "Personal"])
let rename = OpenClawChatGatewayRequests.sessionGroupsRename(name: "Work", to: "Projects")
let delete = OpenClawChatGatewayRequests.sessionGroupsDelete(name: "Personal")
#expect(list.method == "sessions.groups.list")
#expect(list.params.isEmpty)
#expect(put.method == "sessions.groups.put")
#expect(put.params["names"]?.value as? [String] == ["Work", "Personal"])
#expect(rename.method == "sessions.groups.rename")
#expect(rename.params["name"]?.value as? String == "Work")
#expect(rename.params["to"]?.value as? String == "Projects")
#expect(delete.method == "sessions.groups.delete")
#expect(delete.params["name"]?.value as? String == "Personal")
}
@Test func `rename clear archive and fork use session mutation contracts`() {
let rename = OpenClawChatGatewayRequests.patchSession(
sessionKey: "agent:main:child",
agentID: nil,
label: .some(nil),
category: nil,
pinned: nil,
archived: nil,
unread: nil)
let archive = OpenClawChatGatewayRequests.patchSession(
sessionKey: "agent:main:child",
agentID: nil,
label: nil,
category: nil,
pinned: nil,
archived: true,
unread: nil)
let fork = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: "agent:main:child",
agentID: nil)
#expect(rename.params["label"]?.value is NSNull)
#expect(archive.params["archived"]?.value as? Bool == true)
#expect(fork.method == "sessions.create")
#expect(fork.params["parentSessionKey"]?.value as? String == "agent:main:child")
#expect(fork.params["fork"]?.value as? Bool == true)
}
@Test func `chat metadata request selects session agent before fallback`() {
let scoped = OpenClawChatGatewayRequests.chatMetadata(
sessionKey: "agent:reviewer:main",
fallbackAgentID: "fallback")
#expect(scoped.method == "chat.metadata")
#expect(scoped.params["agentId"]?.value as? String == "reviewer")
let global = OpenClawChatGatewayRequests.chatMetadata(
sessionKey: "global",
fallbackAgentID: "reviewer")
#expect(global.params["agentId"]?.value as? String == "reviewer")
}
@Test func `commands request selects session agent before fallback`() {
let scoped = OpenClawChatGatewayRequests.commandsList(
sessionKey: "agent:reviewer:main",
fallbackAgentID: "fallback")
#expect(scoped.params["scope"]?.value as? String == "text")
#expect(scoped.params["includeArgs"]?.value as? Bool == true)
#expect(scoped.params["agentId"]?.value as? String == "reviewer")
let global = OpenClawChatGatewayRequests.commandsList(
sessionKey: "global",
fallbackAgentID: "reviewer")
#expect(global.params["agentId"]?.value as? String == "reviewer")
}
@Test func `send request shares attachment encoding and timeout policy`() throws {
let request = OpenClawChatGatewayRequests.sendMessage(
sessionKey: "global",
agentID: " reviewer ",
expectedSessionRoutingContract: " per-sender|main|reviewer ",
message: "hello",
thinking: " low ",
idempotencyKey: "send-1",
attachments: [.init(type: "image", mimeType: "image/png", fileName: "a.png", content: "abc")])
#expect(request.method == "chat.send")
#expect(request.timeoutMs == 30000)
#expect(request.params["agentId"]?.value as? String == "reviewer")
#expect(request.params["expectedSessionRoutingContract"]?.value as? String == "per-sender|main|reviewer")
#expect(request.params["thinking"]?.value as? String == "low")
#expect(request.params["timeoutMs"] == nil)
let encoded = try JSONEncoder().encode(request.params["attachments"])
#expect(String(decoding: encoded, as: UTF8.self).contains("a.png"))
}
@Test func `send request omits inherited thinking override`() {
let inherited = OpenClawChatGatewayRequests.sendMessage(
sessionKey: "global",
agentID: nil,
expectedSessionRoutingContract: nil,
message: "inherit",
thinking: nil,
idempotencyKey: "send-inherit",
attachments: [])
#expect(inherited.params["thinking"] == nil)
}
@Test func `question resolve request preserves answer arrays`() throws {
let request = OpenClawChatGatewayRequests.resolveQuestion(
id: "ask_123",
answers: ["meal": ["Pizza", "Salad"]])
#expect(request.method == "question.resolve")
let data = try JSONEncoder().encode(request.params)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let answers = try #require(object["answers"] as? [String: Any])
#expect(answers["meal"] as? [String] == ["Pizza", "Salad"])
}
@Test func `question get request carries id`() {
let request = OpenClawChatGatewayRequests.questionGet(id: "ask_123")
#expect(request.method == "question.get")
#expect(request.params["id"]?.value as? String == "ask_123")
}
@Test func `question cancel request uses resolve cancel contract`() {
let request = OpenClawChatGatewayRequests.cancelQuestion(id: "ask_123")
#expect(request.method == "question.resolve")
#expect(request.params["id"]?.value as? String == "ask_123")
#expect(request.params["cancel"]?.value as? Bool == true)
#expect(request.params["answers"] == nil)
}
@Test func `long running requests share exact gateway timeout margins`() {
#expect(OpenClawChatGatewayRequests.agentWait(runID: "run-1", timeoutMs: 1).timeoutMs == 5001)
#expect(OpenClawChatGatewayRequests.agentWait(runID: "run-1", timeoutMs: 30000).timeoutMs == 35000)
#expect(OpenClawChatGatewayRequests.compactSession(
sessionKey: "main",
agentID: nil).timeoutMs == 0)
}
}
struct ChatGatewayPayloadCodecTests {
@Test func `session key extracts canonical agent identity`() {
#expect(OpenClawChatSessionKey.agentID(from: " agent:Reviewer:main ") == "Reviewer")
#expect(OpenClawChatSessionKey.agentID(from: "agent::main") == nil)
#expect(OpenClawChatSessionKey.agentID(from: "global") == nil)
}
@Test func `agent wait distinguishes terminal and retryable timeouts`() throws {
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"completed"}"#.utf8)) == .terminal(.completed))
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"pending"}"#.utf8)) == .checkAgain)
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"timeout","timeoutPhase":"queue"}"#.utf8)) == .checkAgain)
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"timeout","timeoutPhase":"provider"}"#.utf8)) ==
.terminal(.failed(message: "Run timed out")))
}
@Test func `routing identity decodes agent and canonical contract`() throws {
let identity = try OpenClawChatGatewayPayloadCodec.decodeSessionRoutingIdentity(
Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"global","agents":[]}"#.utf8))
#expect(identity.defaultAgentID == "work")
#expect(identity.contract == "global|primary|work")
}
@Test func `model choices preserve metadata and replace blank names`() throws {
let choices = try OpenClawChatGatewayPayloadCodec.decodeModelChoices(Data(
#"{"models":[{"id":"gpt-5","name":" ","provider":"openai","contextWindow":200000,"reasoning":true}]}"#
.utf8))
#expect(choices == [OpenClawChatModelChoice(
modelID: "gpt-5",
name: "gpt-5",
provider: "openai",
contextWindow: 200_000,
reasoning: true)])
}
@Test func `command choice normalizes source aliases and identity`() {
let choice = OpenClawChatGatewayPayloadCodec.commandChoice(CommandEntry(
name: "review",
textaliases: [" /review ", ""],
description: "Review changes",
source: AnyCodable("plugin"),
scope: AnyCodable("text"),
acceptsargs: true))
#expect(choice.id == "plugin:review:/review")
#expect(choice.textAliases == ["/review"])
#expect(choice.source == .plugin)
#expect(choice.acceptsArgs)
}
@Test func `event frames map to shared chat transport events`() {
let sessionsChanged = EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable([
"sessionKey": AnyCodable("agent:main:main"),
"agentId": AnyCodable("main"),
"reason": AnyCodable("command-metadata"),
]))
guard case let .sessionsChanged(change) = OpenClawChatGatewayPayloadCodec.event(from: sessionsChanged)
else {
Issue.record("expected sessionsChanged")
return
}
#expect(change == .init(
sessionKey: "agent:main:main",
agentId: "main",
reason: "command-metadata"))
let swarmNote = EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable([
"sessionKey": AnyCodable("agent:main:main"),
"reason": AnyCodable("swarm-note"),
"swarmGroupId": AnyCodable("swarm:agent:main:main:run-1"),
"kind": AnyCodable("phase"),
"text": AnyCodable("Research"),
]))
guard case let .sessionsChanged(note) = OpenClawChatGatewayPayloadCodec.event(from: swarmNote)
else {
Issue.record("expected swarm sessionsChanged")
return
}
#expect(note.swarmGroupId == "swarm:agent:main:main:run-1")
#expect(note.kind == "phase")
#expect(note.text == "Research")
let lifecycleChanged = EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable([
"sessionKey": AnyCodable("agent:main:main"),
"phase": AnyCodable("end"),
"runId": AnyCodable("run-1"),
"session": AnyCodable([
"key": AnyCodable("agent:main:main"),
"updatedAt": AnyCodable(30000),
"status": AnyCodable("done"),
"hasActiveRun": AnyCodable(false),
"runtimeMs": AnyCodable(30000),
"outputTokens": AnyCodable(42),
"activeRunIds": AnyCodable([]),
]),
]))
guard case let .sessionsChanged(lifecycle) = OpenClawChatGatewayPayloadCodec.event(
from: lifecycleChanged)
else {
Issue.record("expected lifecycle sessionsChanged")
return
}
#expect(lifecycle.reason.isEmpty)
#expect(lifecycle.phase == "end")
#expect(lifecycle.runId == "run-1")
#expect(lifecycle.session?.key == "agent:main:main")
#expect(lifecycle.session?.status == "done")
#expect(lifecycle.session?.runtimeMs == 30000)
#expect(lifecycle.session?.outputTokens == 42)
#expect(lifecycle.session?.activeRunIds == [])
let chat = EventFrame(
type: "event",
event: "chat",
payload: AnyCodable([
"runId": AnyCodable("run-1"),
"sessionKey": AnyCodable("main"),
"state": AnyCodable("final"),
]))
guard case let .chat(payload) = OpenClawChatGatewayPayloadCodec.event(from: chat) else {
Issue.record("expected chat")
return
}
#expect(payload.runId == "run-1")
#expect(payload.sessionKey == "main")
#expect(payload.state == "final")
let observer = EventFrame(
type: "event",
event: "session.observer",
payload: AnyCodable([
"sessionKey": AnyCodable("main"),
"runId": AnyCodable("run-1"),
"revision": AnyCodable(2),
"updatedAt": AnyCodable(300),
"headline": AnyCodable("Wrapping up"),
"health": AnyCodable("wrapping-up"),
]))
guard case let .sessionObserver(digest) = OpenClawChatGatewayPayloadCodec.event(from: observer)
else {
Issue.record("expected sessionObserver")
return
}
#expect(digest.sessionkey == "main")
#expect(digest.runid == "run-1")
#expect(digest.revision == 2)
let task = EventFrame(
type: "event",
event: "task",
payload: AnyCodable([
"action": AnyCodable("upserted"),
"task": AnyCodable([
"id": AnyCodable("task-1"),
"runtime": AnyCodable("subagent"),
"status": AnyCodable("running"),
"sessionKey": AnyCodable("agent:main:main"),
"lastActivity": AnyCodable("Editing ChatView.swift"),
"diffStat": AnyCodable([
"files": AnyCodable(1),
"added": AnyCodable(8),
"removed": AnyCodable(2),
]),
]),
]))
guard case let .task(.upserted(summary)) = OpenClawChatGatewayPayloadCodec.event(from: task)
else {
Issue.record("expected task upsert")
return
}
#expect(summary.id == "task-1")
#expect(summary.sessionkey == "agent:main:main")
#expect(summary.lastactivity == "Editing ChatView.swift")
#expect(summary.diffstat?["added"]?.intValue == 8)
#expect(OpenClawChatGatewayPayloadCodec.event(from: EventFrame(
type: "event",
event: "unknown")) == nil)
}
@Test func `session change decoding distinguishes absent fields from explicit clears`() {
func decode(_ fields: [String: AnyCodable]) throws -> OpenClawChatSessionsChangedEvent {
let frame = EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable(fields))
guard case let .sessionsChanged(change) = OpenClawChatGatewayPayloadCodec.event(from: frame)
else {
throw CancellationError()
}
return change
}
let partial = try? decode([
"sessionKey": AnyCodable("main"),
"reason": AnyCodable("message"),
"updatedAt": AnyCodable(200),
])
#expect(partial?.agentStatusPresent == false)
#expect(partial?.observerDigestPresent == false)
#expect(partial?.statusPresent == false)
#expect(partial?.lastRunErrorPresent == false)
let cleared = try? decode([
"sessionKey": AnyCodable("main"),
"reason": AnyCodable("patch"),
"agentStatus": AnyCodable(NSNull()),
"observerDigest": AnyCodable(NSNull()),
"status": AnyCodable(NSNull()),
"lastRunError": AnyCodable(NSNull()),
])
#expect(cleared?.agentStatusPresent == true)
#expect(cleared?.observerDigestPresent == true)
#expect(cleared?.statusPresent == true)
#expect(cleared?.lastRunErrorPresent == true)
}
@Test func `session change decodes nested fallback with outer null precedence and no reason`() throws {
let data = Data("""
{
"sessionKey": null,
"status": null,
"session": {
"key": "agent:main:child",
"agentId": "main",
"parentSessionKey": "agent:main:parent",
"status": "running",
"lastRunError": null,
"hasActiveRun": true,
"swarmGroupId": "swarm:agent:main:parent:turn-1"
}
}
""".utf8)
let event = try JSONDecoder().decode(OpenClawChatSessionsChangedEvent.self, from: data)
#expect(event.reason.isEmpty)
#expect(event.sessionKey == nil)
#expect(event.agentId == "main")
#expect(event.parentSessionKey == "agent:main:parent")
#expect(event.status == nil)
#expect(event.statusPresent)
#expect(event.lastRunError == nil)
#expect(event.lastRunErrorPresent)
#expect(event.hasActiveRun == true)
#expect(event.swarmGroupId == "swarm:agent:main:parent:turn-1")
}
@Test func `session change remains codable without exposing presence flags`() throws {
let event = OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:work",
agentId: "main",
reason: "run-progress",
updatedAt: 200,
lastReadAt: 100,
agentStatus: .init(note: "Reviewing", expiresAt: 500, attention: "hand"),
observerDigest: .init(
runId: "run-1",
revision: 2,
updatedAt: 200,
headline: "On track",
health: "on-track"),
status: "running",
lastRunError: "Previous warning",
hasActiveRun: true,
activeRunIds: ["run-1"],
startedAt: 50,
endedAt: nil)
let data = try JSONEncoder().encode(event)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(object["agentStatusPresent"] == nil)
#expect(object["observerDigestPresent"] == nil)
#expect(object["statusPresent"] == nil)
#expect(object["lastRunErrorPresent"] == nil)
#expect(try JSONDecoder().decode(OpenClawChatSessionsChangedEvent.self, from: data) == event)
}
}