Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
2026-06-06 04:41:32 -05:00

3911 lines
153 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import OpenClawKit
import Testing
@testable import OpenClawChatUI
private func chatTextMessage(role: String, text: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [["type": "text", "text": text]],
"timestamp": timestamp,
])
}
private func chatErrorMessage(role: String, errorMessage: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [],
"timestamp": timestamp,
"stopReason": "error",
"errorMessage": errorMessage,
])
}
private func historyPayload(
sessionKey: String = "main",
sessionId: String? = "sess-main",
messages: [AnyCodable] = []) -> OpenClawChatHistoryPayload
{
OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: sessionId,
messages: messages,
thinkingLevel: "off")
}
private func sessionEntry(key: String, updatedAt: Double) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func thinkingOption(_ id: String, label: String? = nil) -> OpenClawChatThinkingLevelOption {
OpenClawChatThinkingLevelOption(id: id, label: label ?? id)
}
private func sessionEntry(
key: String,
updatedAt: Double,
model: String?,
modelProvider: String? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: modelProvider,
model: model,
contextTokens: nil)
}
private func modelChoice(id: String, name: String, provider: String = "anthropic") -> OpenClawChatModelChoice {
OpenClawChatModelChoice(modelID: id, name: name, provider: provider, contextWindow: nil)
}
private func makeViewModel(
sessionKey: String = "main",
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil,
setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil,
createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil,
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true],
initialThinkingLevel: String? = nil,
onSessionChanged: (@MainActor (String) -> Void)? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil) async
-> (TestChatTransport, OpenClawChatViewModel)
{
let transport = TestChatTransport(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
requestHistoryHook: requestHistoryHook,
setActiveSessionHook: setActiveSessionHook,
createSessionHook: createSessionHook,
resetSessionHook: resetSessionHook,
compactSessionHook: compactSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook,
waitForRunCompletionHook: waitForRunCompletionHook,
healthResponses: healthResponses)
let vm = await MainActor.run {
OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: initialThinkingLevel,
onSessionChanged: onSessionChanged,
onThinkingLevelChanged: onThinkingLevelChanged)
}
return (transport, vm)
}
private func loadAndWaitBootstrap(
vm: OpenClawChatViewModel,
sessionId: String? = nil) async throws
{
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") {
await MainActor.run {
vm.healthOK && (sessionId == nil || vm.sessionId == sessionId)
}
}
}
private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") async {
await MainActor.run {
vm.input = text
vm.send()
}
}
private func waitForLastSentRunId(_ transport: TestChatTransport) async throws -> String {
try await waitUntil("transport send called") {
await transport.lastSentRunId() != nil
}
return try #require(await transport.lastSentRunId())
}
private func waitForSentRunId(after sentRunCount: Int, _ transport: TestChatTransport) async throws -> String {
try await waitUntil("transport send called") {
await transport.sentRunIds().count > sentRunCount
}
return try #require(await transport.sentRunIds().last)
}
@discardableResult
private func sendMessageAndEmitFinal(
transport: TestChatTransport,
vm: OpenClawChatViewModel,
text: String,
sessionKey: String = "main") async throws -> String
{
let sentRunCount = await transport.sentRunIds().count
await sendUserMessage(vm, text: text)
let runId = try await waitForSentRunId(after: sentRunCount, transport)
try await waitUntil("send is pending or refreshed") {
await MainActor.run {
vm.pendingRunCount == 1 || (!vm.isSending && vm.pendingRunCount == 0)
}
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: sessionKey,
state: "final",
message: nil,
errorMessage: nil)))
return runId
}
private func emitAssistantText(
transport: TestChatTransport,
runId: String,
text: String,
seq: Int = 1)
{
transport.emit(
.agent(
OpenClawAgentEventPayload(
runId: runId,
seq: seq,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable(text)])))
}
private func emitToolStart(
transport: TestChatTransport,
runId: String,
seq: Int = 2)
{
transport.emit(
.agent(
OpenClawAgentEventPayload(
runId: runId,
seq: seq,
stream: "tool",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: [
"phase": AnyCodable("start"),
"name": AnyCodable("demo"),
"toolCallId": AnyCodable("t1"),
"args": AnyCodable(["x": 1]),
])))
}
private func emitAgentLifecycleEnd(
transport: TestChatTransport,
runId: String,
seq: Int = 3)
{
transport.emit(
.agent(
OpenClawAgentEventPayload(
runId: runId,
seq: seq,
stream: "lifecycle",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["phase": AnyCodable("end")])))
}
private func emitExternalFinal(
transport: TestChatTransport,
runId: String = "other-run",
sessionKey: String = "main")
{
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: sessionKey,
state: "final",
message: nil,
errorMessage: nil)))
}
@MainActor
private final class CallbackBox {
var values: [String] = []
}
private actor AsyncGate {
private var continuation: CheckedContinuation<Void, Never>?
func wait() async {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func open() {
self.continuation?.resume()
self.continuation = nil
}
}
private actor AsyncCounter {
private var value: Int
init(_ initialValue: Int = 0) {
self.value = initialValue
}
func increment() -> Int {
self.value += 1
return self.value
}
func current() -> Int {
self.value
}
}
private actor SessionSubscribeGate {
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
await withCheckedContinuation { continuation in
self.waiters.append(continuation)
}
}
func release() {
let waiters = self.waiters
self.waiters = []
for waiter in waiters {
waiter.resume()
}
}
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var healthCallCount: Int = 0
var activeSessionKeys: [String] = []
var createdSessionKeys: [String] = []
var createdParentSessionKeys: [String?] = []
var resetSessionKeys: [String] = []
var compactSessionKeys: [String] = []
var sentSessionKeys: [String] = []
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
var waitCompletionRunIds: [String] = []
var patchedModels: [String?] = []
var patchedThinkingLevels: [String] = []
}
private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [OpenClawChatHistoryPayload]
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let requestHistoryHook: (@Sendable (String) async throws -> Void)?
private let setActiveSessionHook: (@Sendable (String) async throws -> Void)?
private let createSessionHook: (@Sendable (String, String?) async throws -> Void)?
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
private let waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)?
private let healthResponses: [Bool]
private let stream: AsyncStream<OpenClawChatTransportEvent>
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
init(
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil,
setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil,
createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil,
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.requestHistoryHook = requestHistoryHook
self.setActiveSessionHook = setActiveSessionHook
self.createSessionHook = createSessionHook
self.resetSessionHook = resetSessionHook
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
self.waitForRunCompletionHook = waitForRunCompletionHook
self.healthResponses = healthResponses
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
}
self.continuation = cont
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
self.stream
}
func setActiveSessionKey(_ sessionKey: String) async throws {
await self.state.activeSessionKeysAppend(sessionKey)
if let setActiveSessionHook {
try await setActiveSessionHook(sessionKey)
}
}
func createSession(
key: String,
label _: String?,
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
{
if let createSessionHook {
try await createSessionHook(key, parentSessionKey)
}
await self.state.createdSessionKeysAppend(key)
await self.state.createdParentSessionKeysAppend(parentSessionKey)
return OpenClawChatCreateSessionResponse(ok: true, key: key, sessionId: "created-\(key)")
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
let idx = await self.state.nextHistoryCallIndex()
if let requestHistoryHook {
try await requestHistoryHook(sessionKey)
}
if idx < self.historyResponses.count {
return self.historyResponses[idx]
}
return self.historyResponses.last ?? OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: nil,
messages: [],
thinkingLevel: "off")
}
func sendMessage(
sessionKey: String,
message _: String,
thinking: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
await self.state.sentSessionKeysAppend(sessionKey)
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
func abortRun(sessionKey _: String, runId: String) async throws {
await self.state.abortedRunIdsAppend(runId)
}
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
let idx = await self.state.nextSessionsCallIndex()
if idx < self.sessionsResponses.count {
return self.sessionsResponses[idx]
}
return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse(
ts: nil,
path: nil,
count: 0,
defaults: nil,
sessions: [])
}
func listModels() async throws -> [OpenClawChatModelChoice] {
let idx = await self.state.nextModelsCallIndex()
if idx < self.modelResponses.count {
return self.modelResponses[idx]
}
return self.modelResponses.last ?? []
}
func setSessionModel(sessionKey _: String, model: String?) async throws {
await self.state.patchedModelsAppend(model)
if let setSessionModelHook = self.setSessionModelHook {
try await setSessionModelHook(model)
}
}
func resetSession(sessionKey: String) async throws {
await self.state.resetSessionKeysAppend(sessionKey)
if let resetSessionHook = self.resetSessionHook {
try await resetSessionHook(sessionKey)
}
}
func compactSession(sessionKey: String) async throws {
await self.state.compactSessionKeysAppend(sessionKey)
if let compactSessionHook = self.compactSessionHook {
try await compactSessionHook(sessionKey)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
try await setSessionThinkingHook(thinkingLevel)
}
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
let idx = await self.state.nextHealthCallIndex()
if idx < self.healthResponses.count {
return self.healthResponses[idx]
}
return self.healthResponses.last ?? true
}
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
await self.state.waitCompletionRunIdsAppend(runId)
return await self.waitForRunCompletionHook?(runId, timeoutMs) ?? false
}
func emit(_ evt: OpenClawChatTransportEvent) {
self.continuation.yield(evt)
}
func lastSentRunId() async -> String? {
let ids = await self.state.sentRunIds
return ids.last
}
func sentRunIds() async -> [String] {
await self.state.sentRunIds
}
func lastSentSessionKey() async -> String? {
let keys = await self.state.sentSessionKeys
return keys.last
}
func abortedRunIds() async -> [String] {
await self.state.abortedRunIds
}
func sentThinkingLevels() async -> [String] {
await self.state.sentThinkingLevels
}
func patchedModels() async -> [String?] {
await self.state.patchedModels
}
func activeSessionKeys() async -> [String] {
await self.state.activeSessionKeys
}
func patchedThinkingLevels() async -> [String] {
await self.state.patchedThinkingLevels
}
func resetSessionKeys() async -> [String] {
await self.state.resetSessionKeys
}
func compactSessionKeys() async -> [String] {
await self.state.compactSessionKeys
}
func waitCompletionRunIds() async -> [String] {
await self.state.waitCompletionRunIds
}
func createdSessionKeys() async -> [String] {
await self.state.createdSessionKeys
}
func createdParentSessionKeys() async -> [String?] {
await self.state.createdParentSessionKeys
}
}
extension TestChatTransportState {
fileprivate func nextHistoryCallIndex() -> Int {
defer { self.historyCallCount += 1 }
return self.historyCallCount
}
fileprivate func nextSessionsCallIndex() -> Int {
defer { self.sessionsCallCount += 1 }
return self.sessionsCallCount
}
fileprivate func nextModelsCallIndex() -> Int {
defer { self.modelsCallCount += 1 }
return self.modelsCallCount
}
fileprivate func nextHealthCallIndex() -> Int {
defer { self.healthCallCount += 1 }
return self.healthCallCount
}
fileprivate func activeSessionKeysAppend(_ v: String) {
self.activeSessionKeys.append(v)
}
fileprivate func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
fileprivate func abortedRunIdsAppend(_ v: String) {
self.abortedRunIds.append(v)
}
fileprivate func waitCompletionRunIdsAppend(_ v: String) {
self.waitCompletionRunIds.append(v)
}
fileprivate func sentThinkingLevelsAppend(_ v: String) {
self.sentThinkingLevels.append(v)
}
fileprivate func patchedModelsAppend(_ v: String?) {
self.patchedModels.append(v)
}
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
self.patchedThinkingLevels.append(v)
}
fileprivate func resetSessionKeysAppend(_ v: String) {
self.resetSessionKeys.append(v)
}
fileprivate func compactSessionKeysAppend(_ v: String) {
self.compactSessionKeys.append(v)
}
fileprivate func createdSessionKeysAppend(_ v: String) {
self.createdSessionKeys.append(v)
}
fileprivate func createdParentSessionKeysAppend(_ v: String?) {
self.createdParentSessionKeys.append(v)
}
fileprivate func sentSessionKeysAppend(_ v: String) {
self.sentSessionKeys.append(v)
}
}
struct ChatViewModelTests {
@Test func `displays error message fallback only for assistant error turns`() throws {
func decodeMessage(role: String, stopReason: String, contentText: String? = nil) throws -> OpenClawChatMessage {
let contentJSON = contentText.map { #"[{"type":"text","text":"\#($0)"}]"# } ?? "[]"
let data = """
{
"role": "\(role)",
"content": \(contentJSON),
"timestamp": 1,
"stopReason": "\(stopReason)",
"errorMessage": "stale provider failure"
}
""".data(using: .utf8)!
return try JSONDecoder().decode(OpenClawChatMessage.self, from: data)
}
let assistantError = try decodeMessage(role: "assistant", stopReason: "error")
#expect(assistantError.content.isEmpty)
#expect(
OpenClawChatMessage.errorDisplayText(
role: assistantError.role,
stopReason: assistantError.stopReason,
errorMessage: assistantError.errorMessage) == "stale provider failure")
#expect(
OpenClawChatMessage.displayText(
contentText: "",
role: assistantError.role,
stopReason: assistantError.stopReason,
errorMessage: assistantError.errorMessage) == "stale provider failure")
let sentinelAssistant = try decodeMessage(
role: "assistant",
stopReason: "error",
contentText: "[assistant turn failed before producing content]")
#expect(
OpenClawChatMessage.displayText(
contentText: sentinelAssistant.content.compactMap(\.text).joined(separator: "\n"),
role: sentinelAssistant.role,
stopReason: sentinelAssistant.stopReason,
errorMessage: sentinelAssistant.errorMessage) == "stale provider failure")
let partialAssistant = try decodeMessage(
role: "assistant",
stopReason: "error",
contentText: "partial answer")
#expect(
OpenClawChatMessage.displayText(
contentText: partialAssistant.content.compactMap(\.text).joined(separator: "\n"),
role: partialAssistant.role,
stopReason: partialAssistant.stopReason,
errorMessage: partialAssistant.errorMessage) == "partial answer")
let stoppedAssistant = try decodeMessage(role: "assistant", stopReason: "stop")
#expect(stoppedAssistant.errorMessage == "stale provider failure")
#expect(stoppedAssistant.content.isEmpty)
#expect(
OpenClawChatMessage.errorDisplayText(
role: stoppedAssistant.role,
stopReason: stoppedAssistant.stopReason,
errorMessage: stoppedAssistant.errorMessage) == nil)
let toolUseAssistant = try decodeMessage(role: "assistant", stopReason: "toolUse")
#expect(toolUseAssistant.errorMessage == "stale provider failure")
#expect(toolUseAssistant.content.isEmpty)
#expect(
OpenClawChatMessage.errorDisplayText(
role: toolUseAssistant.role,
stopReason: toolUseAssistant.stopReason,
errorMessage: toolUseAssistant.errorMessage) == nil)
}
@Test func `streams assistant and clears on final`() async throws {
let sessionId = "sess-main"
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "assistant",
text: "final answer",
timestamp: Date().timeIntervalSince1970 * 1000),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try await waitForLastSentRunId(transport)
emitAssistantText(transport: transport, runId: runId, text: "streaming…")
try await waitUntil("assistant stream visible") {
await MainActor.run { vm.streamingAssistantText == "streaming…" }
}
emitToolStart(transport: transport, runId: runId)
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
try await waitUntil("history refresh") {
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
}
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func `renders final chat event message when history is stale`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello")
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try await waitForLastSentRunId(transport)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "reply from final event",
timestamp: Date().timeIntervalSince1970 * 1000),
errorMessage: nil)))
try await waitUntil("final event message visible") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.contains { message in
message.role == "assistant" &&
message.content.contains { $0.text == "reply from final event" }
}
}
}
}
@Test func `completion wait refreshes history and clears pending run`() async throws {
let sessionId = "sess-main"
let now = (Date().timeIntervalSince1970 * 1000) + 10000
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(sessionId: sessionId, messages: [])
let history3 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "assistant",
text: "completed after wait",
timestamp: now + 60000),
])
let (transport, vm) = await makeViewModel(
historyResponses: [history1, history2, history3],
waitForRunCompletionHook: { _, _ in true })
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello")
try await waitUntil("agent wait called") {
await !(transport.waitCompletionRunIds()).isEmpty
}
let runId = try await waitForLastSentRunId(transport)
#expect(await transport.waitCompletionRunIds() == [runId])
try await waitUntil("completion wait refresh clears pending run") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.contains { message in
message.role == "assistant" &&
message.content.contains { $0.text == "completed after wait" }
}
}
}
}
@Test func `agent lifecycle end refreshes history and clears pending run`() async throws {
let sessionId = "sess-main"
let now = (Date().timeIntervalSince1970 * 1000) + 10000
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(sessionId: sessionId, messages: [])
let history3 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "assistant",
text: "completed from lifecycle",
timestamp: now + 60000),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history3])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello")
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try await waitForLastSentRunId(transport)
emitAssistantText(transport: transport, runId: runId, text: "streaming reply")
emitToolStart(transport: transport, runId: runId)
emitAgentLifecycleEnd(transport: transport, runId: runId)
try await waitUntil("lifecycle end refresh clears pending run") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.streamingAssistantText == nil &&
vm.pendingToolCalls.isEmpty &&
vm.messages.contains { message in
message.role == "assistant" &&
message.content.contains { $0.text == "completed from lifecycle" }
}
}
}
}
@Test func `pending run blocks second main send`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId, messages: [])
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "first")
try await waitUntil("first send becomes pending") {
await MainActor.run { vm.pendingRunCount == 1 && !vm.isSending }
}
let firstRunIds = await transport.sentRunIds()
#expect(firstRunIds.count == 1)
#expect(await MainActor.run { !vm.canSend })
await MainActor.run {
vm.input = "second"
vm.send()
}
try await Task.sleep(for: .milliseconds(50))
#expect(await transport.sentRunIds() == firstRunIds)
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.input } == "second")
}
@Test func `keeps optimistic user message when final refresh returns only assistant history`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "assistant",
text: "final answer",
timestamp: now + 1),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "hello from mac webchat")
try await waitUntil("assistant history refreshes without dropping user message") {
await MainActor.run {
let texts = vm.messages.map { message in
(message.role, message.content.compactMap(\.text).joined(separator: "\n"))
}
return texts.contains(where: { $0.0 == "assistant" && $0.1 == "final answer" }) &&
texts.contains(where: { $0.0 == "user" && $0.1 == "hello from mac webchat" })
}
}
}
@Test func `keeps optimistic user message when final refresh history is temporarily empty`() async throws {
let sessionId = "sess-main"
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(sessionId: sessionId, messages: [])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "hello from mac webchat")
try await waitUntil("empty refresh does not clear optimistic user message") {
await MainActor.run {
vm.messages.contains { message in
message.role == "user" &&
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
}
}
}
}
@Test func `does not duplicate user message when refresh returns canonical timestamp`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "hello from mac webchat",
timestamp: now + 5000),
chatTextMessage(
role: "assistant",
text: "final answer",
timestamp: now + 6000),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "hello from mac webchat")
try await waitUntil("canonical refresh keeps one user message") {
await MainActor.run {
let userMessages = vm.messages.filter { message in
message.role == "user" &&
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
}
let hasAssistant = vm.messages.contains { message in
message.role == "assistant" &&
message.content.compactMap(\.text).joined(separator: "\n") == "final answer"
}
return hasAssistant && userMessages.count == 1
}
}
}
@Test func `preserves repeated optimistic user messages with identical content during refresh`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "retry",
timestamp: now + 5000),
chatTextMessage(
role: "assistant",
text: "first answer",
timestamp: now + 6000),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "retry")
try await waitUntil("first retry completes") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.contains { message in
message.role == "assistant" &&
message.content.compactMap(\.text).joined(separator: "\n") == "first answer"
}
}
}
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "retry")
try await waitUntil("repeated optimistic user message is preserved") {
await MainActor.run {
let retryMessages = vm.messages.filter { message in
message.role == "user" &&
message.content.compactMap(\.text).joined(separator: "\n") == "retry"
}
let hasAssistant = vm.messages.contains { message in
message.role == "assistant" &&
message.content.compactMap(\.text).joined(separator: "\n") == "first answer"
}
return hasAssistant && retryMessages.count == 2
}
}
}
@Test func `run refresh does not resurrect old user turns omitted by bounded history`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let oldMessages = [
chatTextMessage(role: "user", text: "old question", timestamp: now - 2000),
chatTextMessage(role: "assistant", text: "old answer", timestamp: now - 1000),
]
let boundedRefreshMessages = [
chatTextMessage(role: "user", text: "current question", timestamp: now + 5000),
chatTextMessage(role: "assistant", text: "current answer", timestamp: now + 6000),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionId: sessionId, messages: oldMessages),
historyPayload(sessionId: sessionId, messages: boundedRefreshMessages),
])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "current question")
try await waitUntil("bounded refresh replaces old history") {
await MainActor.run {
let texts = vm.messages.map { message in
message.content.compactMap(\.text).joined(separator: "\n")
}
return texts.contains("current answer") &&
!texts.contains("old question") &&
!texts.contains("old answer")
}
}
}
@Test @MainActor func `bounded repeated same text reply invalidates older stale refresh`() async throws {
let sessionId = "sess-main"
let staleRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let staleRefreshReleasedCount = AsyncCounter()
let now = (Date().timeIntervalSince1970 * 1000) + 10000
let firstTurn = [
chatTextMessage(role: "user", text: "retry", timestamp: now),
chatTextMessage(role: "assistant", text: "first answer", timestamp: now + 1),
]
let latestBoundedTurn = [
chatTextMessage(role: "user", text: "retry", timestamp: now + 2),
chatTextMessage(role: "assistant", text: "second answer", timestamp: now + 3),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionId: sessionId, messages: firstTurn),
historyPayload(sessionId: sessionId, messages: firstTurn),
historyPayload(sessionId: sessionId, messages: latestBoundedTurn),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await historyCount.increment()
if count == 2 {
await staleRefreshGate.wait()
_ = await staleRefreshReleasedCount.increment()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
transport.emit(OpenClawChatTransportEvent.seqGap)
try await waitUntil("stale refresh is in flight") {
await historyCount.current() == 2
}
vm.input = "retry"
vm.send()
_ = try await waitForLastSentRunId(transport)
try await waitUntil("bounded second answer applies") {
await MainActor.run {
vm.sessionId == sessionId &&
vm.messages.contains { message in
message.content.contains { $0.text == "second answer" }
}
}
}
await staleRefreshGate.release()
try await waitUntil("stale refresh resumes") {
await staleRefreshReleasedCount.current() == 1
}
#expect(await MainActor.run {
vm.messages.contains { message in
message.content.contains { $0.text == "second answer" }
}
})
}
@Test func `accepts canonical session key events for own pending run`() async throws {
let history1 = historyPayload()
let history2 = historyPayload(
messages: [
chatTextMessage(
role: "assistant",
text: "from history",
timestamp: Date().timeIntervalSince1970 * 1000),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try await waitForLastSentRunId(transport)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "agent:main:main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
try await waitUntil("history refresh") {
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
}
}
@Test func `surfaces assistant error message after own run refresh`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload()
let history2 = historyPayload(
messages: [
chatErrorMessage(
role: "assistant",
errorMessage: "You have hit your ChatGPT usage limit (plus plan). Try again in ~28 min.",
timestamp: now),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try await waitForLastSentRunId(transport)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "error",
message: nil,
errorMessage: "You have hit your ChatGPT usage limit (plus plan). Try again in ~28 min.")))
try await waitUntil("pending run clears after error") {
await MainActor.run { vm.pendingRunCount == 0 }
}
try await waitUntil("history refresh shows assistant error message") {
await MainActor.run {
vm.messages.contains(where: { message in
message.role == "assistant" &&
OpenClawChatMessage.displayText(
contentText: message.content.compactMap(\.text).joined(separator: "\n"),
role: message.role,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
.contains("You have hit your ChatGPT usage limit")
})
}
}
}
@Test func `accepts canonical session key events for external runs`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "first", timestamp: now)])
let history2 = historyPayload(
messages: [
chatTextMessage(role: "user", text: "first", timestamp: now),
chatTextMessage(role: "assistant", text: "from external run", timestamp: now + 1),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.count == 1 } }
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "external-run",
sessionKey: "agent:main:main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("history refresh after canonical external event") {
await MainActor.run { vm.messages.count == 2 }
}
}
@Test func `appends external session user message for active session`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
sessionKey: "agent:aiden:main",
historyResponses: [historyPayload(sessionKey: "agent:aiden:main")])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:aiden:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "spoken transcript",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now),
messageId: "msg-1",
messageSeq: 1)))
try await waitUntil("external transcript visible") {
await MainActor.run {
vm.messages.count == 1 &&
vm.messages.first?.role == "user" &&
vm.messages.first?.content.first?.text == "spoken transcript"
}
}
}
@Test func `appends global session user message for selected agent`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
sessionKey: "agent:work:global",
historyResponses: [historyPayload(sessionKey: "agent:work:global")])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "global",
agentId: "work",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "global transcript",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now),
messageId: "msg-global-work",
messageSeq: 1)))
try await waitUntil("selected agent global transcript visible") {
await MainActor.run {
vm.messages.count == 1 &&
vm.messages.first?.role == "user" &&
vm.messages.first?.content.first?.text == "global transcript"
}
}
}
@Test func `ignores global session user message for different agent`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
sessionKey: "agent:work:global",
historyResponses: [historyPayload(sessionKey: "agent:work:global")])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "global",
agentId: "main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "wrong global transcript",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now),
messageId: "msg-global-main",
messageSeq: 1)))
try await Task.sleep(nanoseconds: 100_000_000)
#expect(await MainActor.run { vm.messages.isEmpty })
}
@Test func `ignores agent main session message for different current main alias`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:sentinel:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "wrong agent transcript",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now),
messageId: "msg-other-agent",
messageSeq: 1)))
try await Task.sleep(nanoseconds: 100_000_000)
#expect(await MainActor.run { vm.messages.isEmpty })
}
@Test func `appends external session assistant message while run pending`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
await sendUserMessage(vm, text: "ping")
try await waitUntil("local run pending") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "assistant",
content: [
OpenClawChatMessageContent(
type: "text",
text: "agent reply",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now + 1),
messageId: "msg-assistant-1",
messageSeq: 2)))
try await waitUntil("assistant transcript visible while pending") {
await MainActor.run {
vm.messages.contains(where: { msg in
msg.role == "assistant" &&
msg.content.first?.text == "agent reply"
})
}
}
}
@Test func `dedupes gateway echo of local user message`() async throws {
let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
await sendUserMessage(vm, text: "echo me")
try await waitUntil("optimistic user message visible") {
await MainActor.run {
vm.messages.count == 1 && vm.messages.first?.content.first?.text == "echo me"
}
}
// Gateway echoes the same user turn over the session-message stream with a
// server-assigned timestamp that differs from the optimistic local one.
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "echo me",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: Date().timeIntervalSince1970 * 1000 + 5000),
messageId: "srv-echo-1",
messageSeq: 1)))
try await Task.sleep(nanoseconds: 50_000_000)
#expect(await MainActor.run {
vm.messages.count(where: { msg in
msg.role == "user" && msg.content.first?.text == "echo me"
}) == 1
})
}
@Test func `appends same content user transcript when it is not local echo`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(messages: [
chatTextMessage(role: "user", text: "repeat", timestamp: now),
]),
])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") {
await MainActor.run { vm.messages.count == 1 }
}
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "repeat",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now + 1000),
messageId: "msg-repeat-2",
messageSeq: 2)))
try await waitUntil("repeated user transcript appended") {
await MainActor.run {
vm.messages.count(where: { msg in
msg.role == "user" && msg.content.first?.text == "repeat"
}) == 2
}
}
}
@Test func `ignores external session user message for other session`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "other",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "other transcript",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: now),
messageId: "msg-2",
messageSeq: 2)))
try await Task.sleep(nanoseconds: 50_000_000)
#expect(await MainActor.run { vm.messages.isEmpty })
}
@Test func `preserves message I ds across history refreshes`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)])
let history2 = historyPayload(
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "world", timestamp: now + 1),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.count == 1 } }
let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
emitExternalFinal(transport: transport)
try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
#expect(firstIdAfter == firstIdBefore)
}
@Test func `clears streaming on external final event`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
emitAssistantText(transport: transport, runId: sessionId, text: "external stream")
emitToolStart(transport: transport, runId: sessionId)
try await waitUntil("streaming active") {
await MainActor.run { vm.streamingAssistantText == "external stream" }
}
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
emitExternalFinal(transport: transport)
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func `seq gap clears pending runs and auto refreshes history`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload()
let history2 = historyPayload(messages: [chatTextMessage(
role: "assistant",
text: "resynced after gap",
timestamp: now)])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "hello")
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(.seqGap)
try await waitUntil("pending run clears on seqGap") {
await MainActor.run { vm.pendingRunCount == 0 }
}
try await waitUntil("history refreshes on seqGap") {
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
}
#expect(await MainActor.run { vm.errorText == nil })
}
@Test func `session choices prefer main and recent`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (2 * 60 * 60 * 1000)
let recentOlder = now - (5 * 60 * 60 * 1000)
let stale = now - (26 * 60 * 60 * 1000)
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 4,
defaults: nil,
sessions: [
sessionEntry(key: "recent-1", updatedAt: recent),
sessionEntry(key: "main", updatedAt: stale),
sessionEntry(key: "recent-2", updatedAt: recentOlder),
sessionEntry(key: "old-1", updatedAt: stale),
])
let (_, vm) = await makeViewModel(historyResponses: [history], sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "recent-1", "recent-2"])
}
@Test func `session choices include current when missing`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: recent),
])
let (_, vm) = await makeViewModel(
sessionKey: "custom",
historyResponses: [history],
sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "custom"])
}
@Test func `session choices use resolved main session key instead of literal main`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
let recentOlder = now - (90 * 60 * 1000)
let history = historyPayload(sessionKey: "Lukes MacBook Pro", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: "Lukes MacBook Pro"),
sessions: [
OpenClawChatSessionEntry(
key: "Lukes MacBook Pro",
kind: nil,
displayName: "Lukes MacBook Pro",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil),
sessionEntry(key: "recent-1", updatedAt: recentOlder),
])
let (_, vm) = await makeViewModel(
sessionKey: "Lukes MacBook Pro",
historyResponses: [history],
sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["Lukes MacBook Pro", "recent-1"])
}
@Test func `session choices hide internal onboarding session`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (2 * 60 * 1000)
let recentOlder = now - (5 * 60 * 1000)
let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: "agent:main:main"),
sessions: [
OpenClawChatSessionEntry(
key: "agent:main:onboarding",
kind: nil,
displayName: "Lukes MacBook Pro",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil),
OpenClawChatSessionEntry(
key: "agent:main:main",
kind: nil,
displayName: "Lukes MacBook Pro",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recentOlder,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil),
])
let (_, vm) = await makeViewModel(
sessionKey: "agent:main:main",
historyResponses: [history],
sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["agent:main:main"])
}
@Test func `new trigger starts fresh agent session without admin reset`() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before new", timestamp: 1),
])
let after = historyPayload(sessionKey: "agent:aiden:ios-new", sessionId: nil, messages: [])
let sessions = OpenClawChatSessionsListResponse(
ts: nil,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: "agent:aiden:main"),
sessions: [
sessionEntry(key: "agent:aiden:main", updatedAt: 1),
])
let (transport, vm) = await makeViewModel(
historyResponses: [before, after],
sessionsResponses: [sessions])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before new" }
}
await MainActor.run {
vm.input = "/new"
vm.send()
}
try await waitUntil("fresh agent session selected") {
await MainActor.run { vm.sessionKey.hasPrefix("agent:aiden:ios-") && vm.messages.isEmpty }
}
let createdKeys = await transport.createdSessionKeys()
#expect(createdKeys.count == 1)
#expect(createdKeys.first?.hasPrefix("agent:aiden:ios-") == true)
#expect(await transport.createdParentSessionKeys() == ["main"])
#expect(await transport.resetSessionKeys().isEmpty)
#expect(await transport.lastSentRunId() == nil)
await sendUserMessage(vm, text: "hello fresh session")
try await waitUntil("send uses fresh session") {
let key = await transport.lastSentSessionKey()
return key?.hasPrefix("agent:aiden:ios-") == true
}
}
@Test func `new trigger falls back to reset when create session is unsupported`() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before new", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after reset fallback", timestamp: 2),
])
let unsupported = NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.create not supported by this transport"])
let (transport, vm) = await makeViewModel(
historyResponses: [before, after],
createSessionHook: { _, _ in throw unsupported })
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before new" }
}
await MainActor.run {
vm.input = "/new"
vm.send()
}
try await waitUntil("reset fallback called") {
await transport.resetSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after reset fallback" }
}
#expect(await transport.createdSessionKeys().isEmpty)
#expect(await MainActor.run { vm.sessionKey } == "main")
#expect(await MainActor.run { vm.errorText } == nil)
#expect(await transport.lastSentRunId() == nil)
}
@Test func `send attempts request when cached health is stale false`() async throws {
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
healthResponses: [false])
await MainActor.run { vm.load() }
try await waitUntil("bootstrap records stale health") {
await MainActor.run { vm.sessionId == "sess-main" && !vm.healthOK }
}
await sendUserMessage(vm, text: "hello despite stale health")
try await waitUntil("send reaches transport") {
await transport.lastSentSessionKey() == "main"
}
#expect(await MainActor.run { vm.errorText } == nil)
}
@Test func `reset trigger resets session and reloads history`() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before reset", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after reset", timestamp: 2),
])
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before reset" }
}
await MainActor.run {
vm.input = "/reset"
vm.send()
}
try await waitUntil("reset called") {
await transport.resetSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after reset" }
}
#expect(await transport.lastSentRunId() == nil)
}
@Test func `compact trigger compacts session and reloads history`() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
])
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact called") {
await transport.compactSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
#expect(await transport.lastSentRunId() == nil)
}
@Test func `compact trigger shows generic error message on failure`() async throws {
let history = historyPayload()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
compactSessionHook: { _ in
throw NSError(
domain: "TestCompact",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "backend details should not leak"])
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact attempted") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
}
@Test func `compact trigger ignores concurrent and immediate repeat requests`() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
])
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [before, after],
compactSessionHook: { _ in
await gate.wait()
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
vm.input = "/compact"
vm.send()
}
try await waitUntil("single compact request issued") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == nil)
await gate.open()
try await waitUntil("history reloaded after compact") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await Task.sleep(for: .milliseconds(50))
#expect(await transport.compactSessionKeys() == ["main"])
#expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.")
}
@Test func `compact trigger allows immediate retry after failure`() async throws {
let history = historyPayload()
let attemptCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
compactSessionHook: { _ in
let next = await attemptCount.increment()
if next == 1 {
throw NSError(
domain: "TestCompact",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "temporary failure"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("first compact attempted") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("second compact attempted") {
await transport.compactSessionKeys() == ["main", "main"]
}
#expect(await MainActor.run { vm.errorText } == nil)
}
@Test func `bootstraps model selection from session and defaults`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.showsModelPicker })
#expect(await MainActor.run { vm.modelSelectionID } == "anthropic/claude-opus-4-6")
#expect(await MainActor.run { vm.defaultModelLabel } == "Default: openai/gpt-4.1-mini")
}
@Test func `selecting default model patches nil and updates selection`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel(OpenClawChatViewModel.defaultModelSelectionID) }
try await waitUntil("session model patched") {
let patched = await transport.patchedModels()
return patched == [nil]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
}
@Test func `selecting provider qualified model disambiguates duplicate model I ds`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "gpt-4.1-mini", modelProvider: "openrouter"),
])
let models = [
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openrouter"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.modelSelectionID } == "openrouter/gpt-4.1-mini")
await MainActor.run { vm.selectModel("openai/gpt-4.1-mini") }
try await waitUntil("provider-qualified model patched") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-4.1-mini"]
}
}
@Test func `slash model I ds stay provider qualified in selection and patch`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(
id: "openai/gpt-5.4",
name: "GPT-5.4 via Vercel AI Gateway",
provider: "vercel-ai-gateway"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("vercel-ai-gateway/openai/gpt-5.4") }
try await waitUntil("slash model patched with provider-qualified ref") {
let patched = await transport.patchedModels()
return patched == ["vercel-ai-gateway/openai/gpt-5.4"]
}
}
@Test func `stale model patch completions do not overwrite newer selection`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("two model patches complete") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
}
@Test func `send waits for in flight model patch to finish`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
await gate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
try await waitUntil("model patch started") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
await sendUserMessage(vm, text: "hello")
try await waitUntil("send entered waiting state") {
await MainActor.run { vm.isSending }
}
#expect(await transport.lastSentRunId() == nil)
await MainActor.run { vm.selectThinkingLevel("high") }
try await waitUntil("thinking level changed while send is blocked") {
await MainActor.run { vm.thinkingLevel == "high" }
}
await gate.open()
try await waitUntil("send released after model patch") {
await transport.lastSentRunId() != nil
}
#expect(await transport.sentThinkingLevels() == ["off"])
}
@Test func `failed latest model selection does not replay after older completion finishes`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
return
}
if model == "openai/gpt-5.4-pro" {
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("older model completion wins after latest failure") {
await MainActor.run {
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func `failed latest model selection restores earlier success without replay`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(100))
return
}
if model == "openai/gpt-5.4-pro" {
try await Task.sleep(for: .milliseconds(200))
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("latest failure restores prior successful model") {
await MainActor.run {
vm.modelSelectionID == "openai/gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test @MainActor func `switch session notifies session changed callback`() async throws {
var changedSessionKeys: [String] = []
let (_, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
onSessionChanged: { changedSessionKeys.append($0) })
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.switchSession(to: "other")
try await waitUntil("user switch bootstrapped target session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
#expect(changedSessionKeys == ["other"])
}
@Test @MainActor func `sync session does not notify session changed callback`() async throws {
var changedSessionKeys: [String] = []
let (_, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
onSessionChanged: { changedSessionKeys.append($0) })
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.syncSession(to: "other")
try await waitUntil("external sync bootstrapped target session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
#expect(changedSessionKeys.isEmpty)
}
@Test @MainActor func `refresh ignores late history from canceled bootstrap for same session`() async throws {
let staleHistoryGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let staleHistoryReleasedCount = AsyncCounter()
let (_, vm) = await makeViewModel(
historyResponses: [
historyPayload(
sessionKey: "main",
sessionId: "sess-stale-load",
messages: [chatTextMessage(role: "assistant", text: "stale load", timestamp: 1)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-current-refresh",
messages: [chatTextMessage(role: "assistant", text: "current refresh", timestamp: 2)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 1 {
await staleHistoryGate.wait()
_ = await staleHistoryReleasedCount.increment()
}
})
vm.load()
try await waitUntil("first bootstrap history request is in flight") {
await mainHistoryCount.current() == 1
}
vm.refresh()
try await waitUntil("refresh bootstrap wins") {
await MainActor.run {
vm.sessionId == "sess-current-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "current refresh" }
}
}
}
await staleHistoryGate.release()
try await waitUntil("stale load history resumes") {
await staleHistoryReleasedCount.current() == 1
}
#expect(await MainActor.run { vm.sessionId } == "sess-current-refresh")
#expect(await MainActor.run {
!vm.messages.contains { message in
message.content.contains { $0.text == "stale load" }
}
})
}
@Test @MainActor func `manual refresh invalidates older same session event refresh`() async throws {
let staleRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let staleRefreshReleasedCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-event-stale",
messages: [chatTextMessage(role: "assistant", text: "stale same-session event", timestamp: 1)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-manual-refresh",
messages: [chatTextMessage(role: "assistant", text: "current manual refresh", timestamp: 2)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await staleRefreshGate.wait()
_ = await staleRefreshReleasedCount.increment()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
transport.emit(.seqGap)
try await waitUntil("same-session event refresh is in flight") {
await mainHistoryCount.current() == 2
}
vm.refresh()
try await waitUntil("manual refresh wins") {
await MainActor.run {
vm.sessionId == "sess-main-manual-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "current manual refresh" }
}
}
}
await staleRefreshGate.release()
try await waitUntil("stale same-session event refresh resumes") {
await staleRefreshReleasedCount.current() == 1
}
#expect(await MainActor.run { vm.sessionId } == "sess-main-manual-refresh")
#expect(await MainActor.run {
!vm.messages.contains { message in
message.content.contains { $0.text == "stale same-session event" }
}
})
}
@Test @MainActor func `failed newer same session refresh does not drop older successful send refresh`() async throws {
let sendRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1),
]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await sendRefreshGate.wait()
}
if count == 3 {
throw NSError(
domain: "ChatViewModelTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "newer event refresh failed"])
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
let runId = try await waitForLastSentRunId(transport)
try await waitUntil("post-send refresh is in flight") {
await mainHistoryCount.current() == 2
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("newer event refresh starts") {
await mainHistoryCount.current() == 3
}
await sendRefreshGate.release()
try await waitUntil("older successful send refresh applies") {
await MainActor.run {
vm.sessionId == "sess-main-send-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "reply from older success" }
}
}
}
}
@Test @MainActor func `newer empty terminal refresh does not drop older assistant run refresh`() async throws {
let sendRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1),
]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-terminal-empty-refresh",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await sendRefreshGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
let runId = try await waitForLastSentRunId(transport)
try await waitUntil("post-send refresh is in flight") {
await mainHistoryCount.current() == 2
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("newer empty terminal refresh applies") {
await MainActor.run {
vm.sessionId == "sess-main-terminal-empty-refresh" &&
vm.pendingRunCount == 0
}
}
await sendRefreshGate.release()
try await waitUntil("older successful send refresh applies assistant reply") {
await MainActor.run {
vm.sessionId == "sess-main-send-refresh" &&
vm.pendingRunCount == 0 &&
vm.messages.contains { message in
message.content.contains { $0.text == "reply from older success" }
}
}
}
}
@Test @MainActor func `newer user only terminal refresh after final event message does not drop older assistant run refresh`() async throws {
let sendRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "reply from durable history", timestamp: now + 1),
]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-terminal-user-only-refresh",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await sendRefreshGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
let runId = try await waitForLastSentRunId(transport)
try await waitUntil("post-send refresh is in flight") {
await mainHistoryCount.current() == 2
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "reply from final event",
timestamp: now + 0.5),
errorMessage: nil)))
try await waitUntil("newer user-only terminal refresh applies") {
await MainActor.run {
vm.sessionId == "sess-main-terminal-user-only-refresh" &&
!vm.messages.contains { message in
message.content.contains { $0.text == "reply from final event" }
}
}
}
await sendRefreshGate.release()
try await waitUntil("older successful send refresh applies durable assistant reply") {
await MainActor.run {
vm.sessionId == "sess-main-send-refresh" &&
vm.pendingRunCount == 0 &&
vm.messages.contains { message in
message.content.contains { $0.text == "reply from durable history" }
}
}
}
}
@Test @MainActor func `manual refresh user only history does not drop older assistant run refresh`() async throws {
let sendRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let (_, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1),
]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-manual-user-only-refresh",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await sendRefreshGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
try await waitUntil("post-send refresh is in flight") {
await mainHistoryCount.current() == 2
}
vm.refresh()
try await waitUntil("manual user-only refresh applies") {
await MainActor.run {
vm.sessionId == "sess-main-manual-user-only-refresh" &&
vm.pendingRunCount == 0
}
}
await sendRefreshGate.release()
try await waitUntil("older successful send refresh applies after manual refresh") {
await MainActor.run {
vm.sessionId == "sess-main-send-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "reply from older success" }
}
}
}
}
@Test @MainActor func `manual refresh older complete history does not drop pending user assistant run refresh`() async throws {
let sendRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let olderCompleteMessages = [
chatTextMessage(role: "user", text: "older question", timestamp: now - 2),
chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1),
]
let (_, vm) = await makeViewModel(
historyResponses: [
historyPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: olderCompleteMessages),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: olderCompleteMessages + [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "reply from pending turn", timestamp: now + 1),
]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-manual-older-complete-refresh",
messages: olderCompleteMessages),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await sendRefreshGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
try await waitUntil("post-send refresh is in flight") {
await mainHistoryCount.current() == 2
}
vm.refresh()
try await waitUntil("manual older complete refresh applies") {
await MainActor.run {
vm.sessionId == "sess-main-manual-older-complete-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "older answer" }
} &&
!vm.messages.contains { message in
message.content.contains { $0.text == "reply from pending turn" }
}
}
}
await sendRefreshGate.release()
try await waitUntil("older successful send refresh applies pending turn answer") {
await MainActor.run {
vm.sessionId == "sess-main-send-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "reply from pending turn" }
}
}
}
}
@Test @MainActor func `manual stale complete refresh after final event does not drop durable reply refresh`() async throws {
let sendRefreshGate = SessionSubscribeGate()
let eventRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let olderCompleteMessages = [
chatTextMessage(role: "user", text: "older question", timestamp: now - 2),
chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: olderCompleteMessages),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: olderCompleteMessages + [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "durable reply", timestamp: now + 1),
]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-event-stale-complete-refresh",
messages: olderCompleteMessages),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-manual-stale-complete-refresh",
messages: olderCompleteMessages),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await sendRefreshGate.wait()
}
if count == 3 {
await eventRefreshGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
let runId = try await waitForLastSentRunId(transport)
try await waitUntil("post-send refresh is in flight") {
await mainHistoryCount.current() == 2
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(role: "assistant", text: "local final reply", timestamp: now + 0.5),
errorMessage: nil)))
try await waitUntil("local final event reply is visible") {
await MainActor.run {
vm.messages.contains { message in
message.content.contains { $0.text == "local final reply" }
}
}
}
vm.refresh()
try await waitUntil("manual stale complete refresh applies without durable reply") {
let historyCount = await mainHistoryCount.current()
let stateMatches = await MainActor.run {
vm.sessionId == "sess-main-manual-stale-complete-refresh" &&
!vm.messages.contains { message in
message.content.contains { $0.text == "durable reply" }
}
}
return historyCount == 4 && stateMatches
}
await eventRefreshGate.release()
try await waitUntil("event stale complete refresh resumes") {
await MainActor.run {
vm.sessionId == "sess-main-event-stale-complete-refresh"
}
}
await sendRefreshGate.release()
try await waitUntil("older durable send refresh applies after manual stale refresh") {
await MainActor.run {
vm.sessionId == "sess-main-send-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "durable reply" }
}
}
}
}
@Test @MainActor func `bootstrap history does not overwrite newer same session refresh`() async throws {
let bootstrapHistoryGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let bootstrapHistoryReleasedCount = AsyncCounter()
let sessions = OpenClawChatSessionsListResponse(
ts: Date().timeIntervalSince1970 * 1000,
path: nil,
count: 1,
defaults: nil,
sessions: [sessionEntry(key: "main", updatedAt: Date().timeIntervalSince1970 * 1000)])
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(
sessionKey: "main",
sessionId: "sess-main-bootstrap-stale",
messages: [chatTextMessage(role: "assistant", text: "stale bootstrap", timestamp: 1)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-event-newer",
messages: [chatTextMessage(role: "assistant", text: "newer event refresh", timestamp: 2)]),
],
sessionsResponses: [sessions],
modelResponses: [[modelChoice(id: "glm-5.1", name: "GLM 5.1")]],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 1 {
await bootstrapHistoryGate.wait()
_ = await bootstrapHistoryReleasedCount.increment()
}
})
vm.load()
try await waitUntil("bootstrap history is in flight") {
await mainHistoryCount.current() == 1
}
transport.emit(.seqGap)
try await waitUntil("newer same-session refresh applies") {
await MainActor.run {
vm.sessionId == "sess-main-event-newer" &&
vm.messages.contains { message in
message.content.contains { $0.text == "newer event refresh" }
}
}
}
await bootstrapHistoryGate.release()
try await waitUntil("bootstrap history resumes") {
await bootstrapHistoryReleasedCount.current() == 1
}
#expect(await MainActor.run { vm.sessionId } == "sess-main-event-newer")
#expect(await MainActor.run {
!vm.messages.contains { message in
message.content.contains { $0.text == "stale bootstrap" }
}
})
try await waitUntil("bootstrap metadata still loads") {
await MainActor.run {
vm.healthOK &&
vm.sessions.contains { $0.key == "main" } &&
vm.modelChoices.contains { $0.modelID == "glm-5.1" }
}
}
}
@Test @MainActor func `stale fallback refresh keeps retrying while run remains pending`() async throws {
let staleFallbackGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let staleFallbackReleasedCount = AsyncCounter()
let now = (Date().timeIntervalSince1970 * 1000) + 10000
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-send-refresh",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-stale-fallback",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-newer-empty-refresh",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-next-fallback",
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "reply from later fallback", timestamp: now + 1),
]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 3 {
await staleFallbackGate.wait()
_ = await staleFallbackReleasedCount.increment()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.input = "hello"
vm.send()
_ = try await waitForLastSentRunId(transport)
try await waitUntil("first fallback refresh is in flight") {
await mainHistoryCount.current() == 3
}
emitExternalFinal(transport: transport, runId: "external-run", sessionKey: "main")
try await waitUntil("newer empty refresh applies") {
await MainActor.run { vm.sessionId == "sess-main-newer-empty-refresh" }
}
await staleFallbackGate.release()
try await waitUntil("stale fallback resumes") {
await staleFallbackReleasedCount.current() == 1
}
try await waitUntil("later fallback still runs", timeoutSeconds: 7.0) {
await mainHistoryCount.current() >= 5
}
try await waitUntil("later fallback applies assistant reply") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.contains { message in
message.content.contains { $0.text == "reply from later fallback" }
}
}
}
}
@Test @MainActor func `stale bootstrap history does not overwrite latest session`() async throws {
let staleHistoryGate = SessionSubscribeGate()
let staleHistoryReleasedCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "other",
sessionId: "sess-other-stale",
messages: [chatTextMessage(role: "assistant", text: "stale other", timestamp: 1)]),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-current",
messages: [chatTextMessage(role: "assistant", text: "current main", timestamp: 2)]),
],
requestHistoryHook: { sessionKey in
if sessionKey == "other" {
await staleHistoryGate.wait()
_ = await staleHistoryReleasedCount.increment()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.syncSession(to: "other")
try await waitUntil("other session subscribe starts") {
await transport.activeSessionKeys().last == "other"
}
vm.syncSession(to: "main")
try await waitUntil("main session wins") {
await MainActor.run {
vm.sessionKey == "main" &&
vm.sessionId == "sess-main-current" &&
vm.messages.contains { message in
message.content.contains { $0.text == "current main" }
}
}
}
await staleHistoryGate.release()
try await waitUntil("stale other history resumes") {
await staleHistoryReleasedCount.current() == 1
}
#expect(await MainActor.run { vm.sessionId } == "sess-main-current")
#expect(await MainActor.run {
!vm.messages.contains { message in
message.content.contains { $0.text == "stale other" }
}
})
}
@Test @MainActor func `session switch clears old latest user before new session refreshes`() async throws {
let staleBootstrapGate = SessionSubscribeGate()
let otherHistoryCount = AsyncCounter()
let staleBootstrapReleasedCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [chatTextMessage(role: "user", text: "main pending question", timestamp: 1)]),
historyPayload(
sessionKey: "other",
sessionId: "sess-other-bootstrap-stale",
messages: [chatTextMessage(role: "assistant", text: "stale other bootstrap", timestamp: 2)]),
historyPayload(
sessionKey: "other",
sessionId: "sess-other-newer-refresh",
messages: [chatTextMessage(role: "assistant", text: "newer other refresh", timestamp: 3)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "other" else { return }
let count = await otherHistoryCount.increment()
if count == 1 {
await staleBootstrapGate.wait()
_ = await staleBootstrapReleasedCount.increment()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.syncSession(to: "other")
try await waitUntil("other bootstrap history is in flight") {
await otherHistoryCount.current() == 1
}
#expect(await MainActor.run { vm.messages.isEmpty })
transport.emit(.seqGap)
try await waitUntil("newer other refresh applies") {
await MainActor.run {
vm.sessionKey == "other" &&
vm.sessionId == "sess-other-newer-refresh" &&
vm.messages.contains { message in
message.content.contains { $0.text == "newer other refresh" }
}
}
}
await staleBootstrapGate.release()
try await waitUntil("stale other bootstrap resumes") {
await staleBootstrapReleasedCount.current() == 1
}
#expect(await MainActor.run { vm.sessionId } == "sess-other-newer-refresh")
#expect(await MainActor.run {
!vm.messages.contains { message in
message.content.contains { $0.text == "stale other bootstrap" }
}
})
}
@Test @MainActor func `stale seq gap refresh does not overwrite latest session`() async throws {
let staleRefreshGate = SessionSubscribeGate()
let mainHistoryCount = AsyncCounter()
let staleRefreshReleasedCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(
sessionKey: "main",
sessionId: "sess-main-gap-stale",
messages: [chatTextMessage(role: "assistant", text: "stale gap", timestamp: 1)]),
historyPayload(
sessionKey: "other",
sessionId: "sess-other-current",
messages: [chatTextMessage(role: "assistant", text: "current other", timestamp: 2)]),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 2 {
await staleRefreshGate.wait()
_ = await staleRefreshReleasedCount.increment()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
transport.emit(.seqGap)
try await waitUntil("seq gap refresh is in flight") {
await mainHistoryCount.current() == 2
}
vm.syncSession(to: "other")
try await waitUntil("other session bootstrap wins") {
await MainActor.run {
vm.sessionKey == "other" &&
vm.sessionId == "sess-other-current" &&
vm.messages.contains { message in
message.content.contains { $0.text == "current other" }
}
}
}
await staleRefreshGate.release()
try await waitUntil("stale seq gap refresh resumes") {
await staleRefreshReleasedCount.current() == 1
}
#expect(await MainActor.run { vm.sessionId } == "sess-other-current")
#expect(await MainActor.run {
!vm.messages.contains { message in
message.content.contains { $0.text == "stale gap" }
}
})
}
@Test @MainActor func `send waiting for model patch does not send after session switch`() async throws {
let modelPatchGate = SessionSubscribeGate()
let modelPatchReleasedCount = AsyncCounter()
let models = [modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai")]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
modelResponses: [models, models],
setSessionModelHook: { _ in
await modelPatchGate.wait()
_ = await modelPatchReleasedCount.increment()
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.selectModel("openai/gpt-5.4")
try await waitUntil("model patch is in flight") {
await transport.patchedModels() == ["openai/gpt-5.4"]
}
vm.input = "hello before switch"
vm.send()
try await waitUntil("send is waiting for model patch") {
await MainActor.run { vm.pendingRunCount == 1 }
}
vm.syncSession(to: "other")
try await waitUntil("session switch clears pending send") {
await MainActor.run {
vm.sessionKey == "other" &&
vm.sessionId == "sess-other" &&
vm.pendingRunCount == 0
}
}
await modelPatchGate.release()
try await waitUntil("model patch resumes") {
await modelPatchReleasedCount.current() == 1
}
try await Task.sleep(for: .milliseconds(100))
#expect(await transport.sentRunIds().isEmpty)
}
@Test @MainActor func `stale sync bootstrap restores current active session subscription`() async throws {
let staleSubscribeGate = SessionSubscribeGate()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
setActiveSessionHook: { sessionKey in
if sessionKey == "other" {
await staleSubscribeGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.syncSession(to: "other")
try await waitUntil("stale subscribe is in flight") {
await transport.activeSessionKeys().last == "other"
}
vm.syncSession(to: "main")
try await waitUntil("current session subscribed") {
let sessionKey = await MainActor.run { vm.sessionKey }
let activeSessionKeys = await transport.activeSessionKeys()
return sessionKey == "main" &&
Array(activeSessionKeys.suffix(2)) == ["other", "main"]
}
await staleSubscribeGate.release()
try await waitUntil("current session resubscribed after stale subscribe") {
await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"]
}
}
@Test @MainActor func `stale subscribe failure reasserts current active session subscription`() async throws {
let staleSubscribeGate = SessionSubscribeGate()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
],
setActiveSessionHook: { sessionKey in
if sessionKey == "other" {
await staleSubscribeGate.wait()
throw NSError(
domain: "TestChatTransport",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "stale subscribe failed after side effect"])
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.syncSession(to: "other")
try await waitUntil("stale subscribe is in flight") {
await transport.activeSessionKeys().last == "other"
}
vm.syncSession(to: "main")
try await waitUntil("current session subscribed") {
await Array(transport.activeSessionKeys().suffix(2)) == ["other", "main"]
}
await staleSubscribeGate.release()
try await waitUntil("current session resubscribed after stale subscribe failure") {
await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"]
}
}
@Test @MainActor func `stale sync repair reasserts latest active session subscription`() async throws {
let staleSubscribeGate = SessionSubscribeGate()
let staleRepairGate = SessionSubscribeGate()
let mainSubscribeCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "final", sessionId: "sess-final"),
],
setActiveSessionHook: { sessionKey in
if sessionKey == "other" {
await staleSubscribeGate.wait()
}
if sessionKey == "main" {
let count = await mainSubscribeCount.increment()
if count == 3 {
await staleRepairGate.wait()
}
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
vm.syncSession(to: "other")
try await waitUntil("stale subscribe is in flight") {
await transport.activeSessionKeys().last == "other"
}
vm.syncSession(to: "main")
try await waitUntil("main session subscribed") {
await Array(transport.activeSessionKeys().suffix(2)) == ["other", "main"]
}
await staleSubscribeGate.release()
try await waitUntil("stale repair is in flight") {
await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"]
}
vm.syncSession(to: "final")
try await waitUntil("newest session subscribed") {
let sessionKey = await MainActor.run { vm.sessionKey }
let activeSessionKeys = await transport.activeSessionKeys()
return sessionKey == "final" && activeSessionKeys.last == "final"
}
await staleRepairGate.release()
try await waitUntil("newest session resubscribed after stale repair") {
await Array(transport.activeSessionKeys().suffix(3)) == ["main", "final", "final"]
}
}
@Test func `switching sessions ignores late model patch completion from previous session`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
sessionsResponses: [sessions, sessions],
modelResponses: [models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched sessions") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
try await waitUntil("late model patch finished") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == nil)
}
@Test func `late model completion does not replay current session selection into previous session`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let initialSessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: "openai/gpt-5.4-pro"),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
],
sessionsResponses: [initialSessions, initialSessions, sessionsAfterOtherSelection],
modelResponses: [models, models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched to other session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
await MainActor.run { vm.selectModel("openai/gpt-5.4-pro") }
try await waitUntil("both model patches issued") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
await MainActor.run { vm.switchSession(to: "main") }
try await waitUntil("switched back to main session") {
await MainActor.run { vm.sessionKey == "main" && vm.sessionId == "sess-main" }
}
try await waitUntil("late model completion updates only the original session") {
await MainActor.run {
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.modelProvider } == nil)
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func `explicit thinking level wins over history and persists changes`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let callbackState = await MainActor.run { CallbackBox() }
let (transport, vm) = await makeViewModel(
historyResponses: [history],
initialThinkingLevel: "high",
onThinkingLevelChanged: { level in
callbackState.values.append(level)
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "high")
await MainActor.run { vm.selectThinkingLevel("medium") }
try await waitUntil("thinking level patched") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
#expect(await MainActor.run { callbackState.values } == ["medium"])
}
@Test func `server provided thinking levels outside menu are preserved for send`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "xhigh")
let (transport, vm) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "xhigh")
await sendUserMessage(vm, text: "hello")
try await waitUntil("send uses preserved thinking level") {
await transport.sentThinkingLevels() == ["xhigh"]
}
}
@Test func `decodes gateway thinking metadata from session list`() throws {
let json = """
{
"defaults": {
"modelProvider": "anthropic",
"model": "claude-opus-4-7",
"thinkingLevels": [
{ "id": "off", "label": "off" },
{ "id": "adaptive", "label": "adaptive" },
{ "id": "max", "label": "maximum" }
],
"thinkingOptions": ["off", "adaptive", "maximum"],
"thinkingDefault": "adaptive"
},
"sessions": [
{
"key": "main",
"modelProvider": "openrouter",
"model": "deepseek/deepseek-v4",
"thinkingLevel": "max",
"thinkingLevels": [
{ "id": "off", "label": "off" },
{ "id": "xhigh", "label": "xhigh" },
{ "id": "max", "label": "max" }
],
"thinkingOptions": ["off", "xhigh", "max"],
"thinkingDefault": "max"
}
]
}
"""
let decoded = try JSONDecoder().decode(
OpenClawChatSessionsListResponse.self,
from: Data(json.utf8))
#expect(decoded.defaults?.modelProvider == "anthropic")
#expect(decoded.defaults?.thinkingLevels?.map(\.id) == ["off", "adaptive", "max"])
#expect(decoded.defaults?.thinkingLevels?.last?.label == "maximum")
#expect(decoded.defaults?.thinkingDefault == "adaptive")
#expect(decoded.sessions.first?.thinkingLevels?.map(\.id) == ["off", "xhigh", "max"])
#expect(decoded.sessions.first?.thinkingDefault == "max")
}
@Test func `session thinking levels drive picker options`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "adaptive")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "openai",
model: "gpt-5.5",
contextTokens: nil,
thinkingLevels: [
thinkingOption("off"),
thinkingOption("low"),
thinkingOption("xhigh"),
thinkingOption("max", label: "maximum"),
],
thinkingOptions: ["off", "low", "xhigh", "maximum"],
thinkingDefault: "xhigh"),
sessions: [
OpenClawChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 1,
sessionId: "sess-main",
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: "adaptive",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "anthropic",
model: "claude-opus-4-7",
contextTokens: nil,
thinkingLevels: [
thinkingOption("off"),
thinkingOption("adaptive"),
thinkingOption("max", label: "maximum"),
],
thinkingOptions: ["off", "adaptive", "maximum"],
thinkingDefault: "adaptive"),
])
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "adaptive")
#expect(await MainActor.run { vm.thinkingLevelOptions.map(\.id) } == ["off", "adaptive", "max"])
#expect(await MainActor.run { vm.thinkingLevelOptions.map(\.label) } == ["off", "adaptive", "maximum"])
}
@Test func `thinking options fallback and current unsupported level stay visible`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "xhigh")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 1,
defaults: nil,
sessions: [
OpenClawChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 1,
sessionId: "sess-main",
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: "xhigh",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "openrouter",
model: "deepseek/deepseek-v4",
contextTokens: nil,
thinkingLevels: nil,
thinkingOptions: ["off", "max"],
thinkingDefault: "max"),
])
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "xhigh")
#expect(await MainActor.run { vm.thinkingLevelOptions.map(\.id) } == ["off", "max", "xhigh"])
#expect(await MainActor.run { vm.thinkingLevelOptions.map(\.label) } == ["off", "max", "xhigh"])
}
@Test func `matching default thinking levels beat legacy row thinking options`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "adaptive")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "anthropic",
model: "claude-opus-4-7",
contextTokens: nil,
thinkingLevels: [
thinkingOption("off"),
thinkingOption("adaptive"),
thinkingOption("max"),
],
thinkingOptions: ["off", "adaptive", "max"],
thinkingDefault: "adaptive"),
sessions: [
OpenClawChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 1,
sessionId: "sess-main",
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: "adaptive",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "anthropic",
model: "claude-opus-4-7",
contextTokens: nil,
thinkingLevels: nil,
thinkingOptions: ["off"],
thinkingDefault: "off"),
])
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevelOptions.map(\.id) } == ["off", "adaptive", "max"])
}
@Test func `default thinking levels do not leak to different session model`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "max")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "anthropic",
model: "claude-opus-4-7",
contextTokens: nil,
thinkingLevels: [
thinkingOption("off"),
thinkingOption("adaptive"),
thinkingOption("max"),
],
thinkingOptions: ["off", "adaptive", "max"],
thinkingDefault: "adaptive"),
sessions: [
OpenClawChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 1,
sessionId: "sess-main",
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: "max",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "openai",
model: "gpt-5.4",
contextTokens: nil),
])
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "max")
#expect(await MainActor.run { vm.thinkingLevelOptions.map(\.id) } ==
["off", "minimal", "low", "medium", "high", "max"])
}
@Test func `stale thinking patch completion reapplies latest selection`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let (transport, vm) = await makeViewModel(
historyResponses: [history],
setSessionThinkingHook: { level in
if level == "medium" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run {
vm.selectThinkingLevel("medium")
vm.selectThinkingLevel("high")
}
try await waitUntil("thinking patch replayed latest selection") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium", "high", "high"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "high")
}
@Test func `clears streaming on external error event`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
emitAssistantText(transport: transport, runId: sessionId, text: "external stream")
try await waitUntil("streaming active") {
await MainActor.run { vm.streamingAssistantText == "external stream" }
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "error",
message: nil,
errorMessage: "boom")))
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
}
@Test func `strips inbound metadata from history messages`() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": """
Conversation info (untrusted metadata):
```json
{ \"sender\": \"openclaw-ios\" }
```
Hello?
"""]],
"timestamp": Date().timeIntervalSince1970 * 1000,
]),
],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history])
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("history loaded") { await MainActor.run { !vm.messages.isEmpty } }
let sanitized = await MainActor.run { vm.messages.first?.content.first?.text }
#expect(sanitized == "Hello?")
}
@Test func `abort requests do not clear pending until aborted event`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try await waitForLastSentRunId(transport)
await MainActor.run { vm.abort() }
try await waitUntil("abortRun called") {
let ids = await transport.abortedRunIds()
return ids == [runId]
}
// Pending remains until the gateway broadcasts an aborted/final chat event.
#expect(await MainActor.run { vm.pendingRunCount } == 1)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "aborted",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
}
}