Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSwarmProgressTests.swift
Peter Steinberger af44cb1a72 feat(native): show Swarm progress in chat (#113850)
* feat(native): show swarm progress

* fix(native): align swarm progress lifecycle

* chore(native): shorten swarm progress source name

* style(macos): pad swarm progress surfaces

* chore(native): use compact swarm source name

* fix(native): harden swarm route ownership

* fix(native): preserve swarm render ownership

* chore(i18n): refresh native swarm inventory

* fix(native): bound swarm refresh ownership

* fix(native): remove unused swarm capability initializer
2026-07-25 15:11:19 -07:00

239 lines
9.0 KiB
Swift

import Foundation
import Testing
@testable import OpenClawChatUI
struct ChatSwarmProgressTests {
@Test func `session rows decode Swarm identity and activity fields`() throws {
let data = Data("""
{
"key": "agent:main:subagent:worker",
"parentSessionKey": "agent:main:main",
"spawnedBy": "agent:main:main",
"subagentRunState": "active",
"swarmGroupId": "swarm:agent:main:main:run-1",
"swarmPhase": "Research",
"swarmPhaseRank": 2,
"swarmLog": "Reading sources"
}
""".utf8)
let row = try JSONDecoder().decode(OpenClawChatSessionEntry.self, from: data)
#expect(row.swarmGroupId == "swarm:agent:main:main:run-1")
#expect(row.parentSessionKey == "agent:main:main")
#expect(row.subagentRunState == "active")
#expect(row.swarmPhase == "Research")
#expect(row.swarmPhaseRank == 2)
#expect(row.swarmLog == "Reading sources")
}
@Test func `activity notes decorate children in observation order`() {
let groupID = "swarm:agent:main:parent:turn-1"
var activity = OpenClawChatSwarmActivityState()
let observedPhase = activity.observe(OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:parent",
reason: "swarm-note",
swarmGroupId: groupID,
kind: "phase",
text: "Research"))
let observedChild = activity.observe(OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:child",
reason: "create",
swarmGroupId: groupID))
let observedLog = activity.observe(OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:parent",
reason: "swarm-note",
swarmGroupId: groupID,
kind: "log",
text: "Comparing sources"))
#expect(observedPhase)
#expect(observedChild)
#expect(observedLog)
let decorated = activity.decorate([self.session(
key: "agent:main:child",
status: "running",
groupID: groupID)])
#expect(decorated.single?.swarmPhase == "Research")
#expect(decorated.single?.swarmPhaseRank == 0)
#expect(decorated.single?.swarmLog == "Comparing sources")
}
@Test func `projection renders active group states and hides terminal groups`() {
let activeGroup = "swarm:agent:main:parent:active"
let finishedGroup = "swarm:agent:main:parent:finished"
let sessions = [
session(key: "queued", status: nil, groupID: activeGroup, subagentRunState: "active"),
session(key: "running", status: "running", groupID: activeGroup),
session(key: "done", status: "done", groupID: activeGroup),
session(key: "failed", status: "timeout", groupID: activeGroup),
session(key: "finished", status: "done", groupID: finishedGroup),
]
let groups = buildOpenClawChatSwarmGroups(sessions: sessions) { $0 == "agent:main:parent" }
#expect(groups.count == 1)
#expect(groups.single?.id == activeGroup)
#expect(groups.single?.running == 1)
#expect(groups.single?.done == 1)
#expect(groups.single?.failed == 1)
#expect(groups.single?.phases.single?.dots.map(\.status) == [.queued, .running, .done, .failed])
}
@Test func `child pager repeats from zero when rows move across offsets`() async throws {
let groupID = "swarm:agent:main:parent:paged"
let pages = [
[
session(key: "zero", status: "running", groupID: groupID),
session(key: "one", status: "running", groupID: groupID),
],
[
session(key: "one", status: "running", groupID: groupID),
session(key: "two", status: "running", groupID: groupID),
],
[
session(key: "zero", status: "running", groupID: groupID),
session(key: "one", status: "done", groupID: groupID),
],
[
session(key: "three", status: "running", groupID: groupID),
session(key: "two", status: "running", groupID: groupID),
],
]
var call = 0
let rows = try await OpenClawChatChildSessionPager.collect { offset in
let page = pages[call]
call += 1
return OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: page.count,
totalCount: 4,
offset: offset,
nextOffset: offset == 0 ? 2 : nil,
hasMore: offset == 0,
defaults: nil,
sessions: page)
}
#expect(Set(rows.map(\.key)) == ["zero", "one", "two", "three"])
#expect(rows.first { $0.key == "one" }?.status == "done")
#expect(call == 4)
}
@Test func `child pager bounds advancing malformed pagination`() async throws {
var call = 0
let rows = try await OpenClawChatChildSessionPager.collect { offset in
call += 1
return OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 1,
totalCount: .max,
offset: offset,
nextOffset: offset + 1,
hasMore: true,
defaults: nil,
sessions: [self.session(
key: "child",
status: "running",
groupID: "swarm:agent:main:parent:paged")])
}
#expect(rows.map(\.key) == ["child"])
#expect(call == 100)
}
@Test func `metadata capability defaults missing Swarm support to disabled`() throws {
let capabilities = try JSONDecoder().decode(
OpenClawChatMetadataCapabilities.self,
from: Data("{}".utf8))
#expect(!capabilities.swarmEnabled)
#expect(throws: DecodingError.self) {
_ = try JSONDecoder().decode(
OpenClawChatMetadataCapabilities.self,
from: Data(#"{"swarmEnabled":"yes"}"#.utf8))
}
}
@Test func `Swarm events ignore other parents`() {
let current = "agent:main:parent"
let ownChild = OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:child",
parentSessionKey: current,
reason: "create",
swarmGroupId: "custom-group")
let otherPhase = OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:other",
reason: "swarm-note",
swarmGroupId: "swarm:agent:main:other:turn",
kind: "phase",
text: "Research")
#expect(SelfContainedSwarmHelpers.eventBelongsToParent(ownChild) { $0 == current })
#expect(!SelfContainedSwarmHelpers.eventBelongsToParent(otherPhase) { $0 == current })
}
@Test func `projection uses derived title before the raw session key`() {
let groupID = "swarm:agent:main:parent:labels"
var row = self.session(key: "agent:main:child", status: "running", groupID: groupID)
row.displayName = nil
row.derivedTitle = "Research worker"
let group = buildOpenClawChatSwarmGroups(sessions: [row]) { $0 == "agent:main:parent" }.single
#expect(group?.phases.single?.dots.single?.label == "Research worker")
}
@Test func `projection caps historical dots while retaining active workers`() {
let groupID = "swarm:agent:main:parent:large"
let completed = (0..<300).map { index in
self.session(key: "done-\(index)", status: "done", groupID: groupID)
}
let running = self.session(key: "running", status: "running", groupID: groupID)
let group = buildOpenClawChatSwarmGroups(
sessions: completed + [running],
matchesParent: { $0 == "agent:main:parent" }).single
#expect(group?.phases.single?.dots.count == 256)
#expect(group?.phases.single?.dots.contains { $0.status == .running } == true)
#expect(group?.phases.single?.hidden == 45)
}
private func session(
key: String,
status: String?,
groupID: String,
subagentRunState: String? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: "direct",
displayName: key,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 1,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil,
parentSessionKey: "agent:main:parent",
status: status,
subagentRunState: subagentRunState,
swarmGroupId: groupID)
}
}
extension Array {
fileprivate var single: Element? {
count == 1 ? self[0] : nil
}
}