Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
Peter Steinberger 91d4e8ed8e feat(apple): session search, archived browsing, and sheet management actions (#101053)
The shared chat session sheet (iOS chat + macOS webchat) gains server-backed
search (sessions.list search param, 250ms debounce, cancellation-safe local
fallback when offline), an Active/Archived scope, swipe/context actions for
pin/rename/archive with optimistic updates and rollback, pin indicators, and
restore-on-open for archived rows. Archiving the open session switches back
to the main session so the composer never points at a send-rejecting session.

OpenClawChatTransport consolidates the two list requirements into canonical
listSessions(limit:search:archived:) with forwarding sugars (unreleased
internal API; all in-repo conformers updated: iOS gateway transport, demo and
fixture transports, previews, macOS webchat, test fakes). macOS webchat also
implements patchSession, giving the sheet's controls a live transport there.

OpenClawChatSessionEntry becomes var-based and gains pinnedAt/archivedAt;
the two full-init rebuild blocks in ChatViewModel collapse to copy-mutation
(the pattern that silently dropped newly added fields), preserving main's
model-identity/thinking-metadata semantics. A shared session list organizer
mirrors gateway ordering (pinnedAt desc, updatedAt desc, key) for cached and
offline lists, and pinned sessions survive the 24h recency cutoff in the
session picker.

Refs #100712
2026-07-07 00:28:38 +01:00

7652 lines
311 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,
contentId: String? = nil,
idempotencyKey: String? = nil) -> AnyCodable
{
var content: [String: Any] = ["type": "text", "text": text]
if let contentId {
content["id"] = contentId
}
var message: [String: Any] = [
"role": role,
"content": [content],
"timestamp": timestamp,
]
if let idempotencyKey {
message["__openclaw"] = ["idempotencyKey": idempotencyKey]
}
return AnyCodable(message)
}
private func chatTextModelMessage(
role: String,
text: String,
timestamp: Double,
idempotencyKey: String? = nil) -> OpenClawChatMessage
{
OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: "text",
text: text,
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: timestamp,
idempotencyKey: idempotencyKey)
}
private func chatErrorMessage(role: String, errorMessage: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [],
"timestamp": timestamp,
"stopReason": "error",
"errorMessage": errorMessage,
])
}
extension [OpenClawChatMessage] {
fileprivate func containsUserText(_ text: String) -> Bool {
contains { message in
message.role == "user" &&
message.content.contains { $0.text == text }
}
}
}
private func historyPayload(
sessionKey: String = "main",
sessionId: String? = "sess-main",
messages: [AnyCodable] = [],
supportsActiveRunState: Bool = true,
hasActiveRun: Bool? = nil,
inFlightRun: OpenClawChatInFlightRun? = nil) -> OpenClawChatHistoryPayload
{
OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: sessionId,
messages: messages,
thinkingLevel: "off",
sessionInfo: supportsActiveRunState
? OpenClawChatSessionInfo(hasActiveRun: hasActiveRun ?? (inFlightRun != nil))
: nil,
inFlightRun: inFlightRun)
}
private func sessionEntry(
key: String,
updatedAt: Double,
displayName: String? = nil,
label: String? = nil,
pinned: Bool = false,
pinnedAt: Double? = nil,
archived: Bool = false,
totalTokens: Int? = nil,
totalTokensFresh: Bool? = nil,
contextTokens: Int? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: displayName,
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: totalTokens,
totalTokensFresh: totalTokensFresh,
modelProvider: nil,
model: nil,
contextTokens: contextTokens,
label: label,
pinned: pinned ? true : nil,
pinnedAt: pinnedAt ?? (pinned ? updatedAt : nil),
archived: archived ? true : nil,
archivedAt: archived ? updatedAt : nil)
}
private func sessionsListResponse(_ sessions: [OpenClawChatSessionEntry]) -> OpenClawChatSessionsListResponse {
OpenClawChatSessionsListResponse(
ts: nil,
path: nil,
count: sessions.count,
defaults: nil,
sessions: sessions)
}
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,
thinkingLevel: String? = nil,
thinkingLevels: [OpenClawChatThinkingLevelOption]? = nil,
thinkingOptions: [String]? = nil,
thinkingDefault: 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: thinkingLevel,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: modelProvider,
model: model,
contextTokens: nil,
thinkingLevels: thinkingLevels,
thinkingOptions: thinkingOptions,
thinkingDefault: thinkingDefault)
}
private func modelChoice(
id: String,
name: String,
provider: String = "anthropic",
reasoning: Bool? = nil) -> OpenClawChatModelChoice
{
OpenClawChatModelChoice(
modelID: id,
name: name,
provider: provider,
contextWindow: nil,
reasoning: reasoning)
}
private func sessionsResponse(
_ session: OpenClawChatSessionEntry,
defaults: OpenClawChatSessionsDefaults? = nil) -> OpenClawChatSessionsListResponse
{
OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 1,
defaults: defaults,
sessions: [session])
}
private func commandChoice(
name: String,
aliases: [String],
description: String = "",
source: OpenClawChatCommandChoice.Source = .command,
acceptsArgs: Bool = false) -> OpenClawChatCommandChoice
{
OpenClawChatCommandChoice(
id: "\(source.rawValue):\(name)",
name: name,
textAliases: aliases,
description: description,
source: source,
acceptsArgs: acceptsArgs)
}
@MainActor
private func makeViewModel(
sessionKey: String = "main",
activeAgentId: String? = nil,
historyResponses: [OpenClawChatHistoryPayload],
sessionRoutingContract: String? = nil,
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
commandResponses: [[OpenClawChatCommandChoice]] = [],
requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil,
historyResponseHook: (@Sendable (String, Int, [String]) async throws -> OpenClawChatHistoryPayload?)? = 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,
renameSessionHook: (@Sendable (String, String) async throws -> Void)? = nil,
setSessionPinnedHook: (@Sendable (String, Bool) async throws -> Void)? = nil,
setSessionArchivedHook: (@Sendable (String, Bool) async throws -> Void)? = nil,
listSessionsHook: (
@Sendable (TestSessionListQuery) async throws -> OpenClawChatSessionsListResponse?)? = nil,
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
sendMessageStatus: String = "ok",
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true],
initialThinkingLevel: String? = nil,
modelPickerStore: ChatModelPickerStore? = nil,
onSessionChanged: (@MainActor (String) -> Void)? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil) async
-> (TestChatTransport, OpenClawChatViewModel)
{
// Default to a throwaway suite so model selections in unrelated tests never
// write favorites/recents into the test host's standard UserDefaults.
let pickerStore = modelPickerStore
??
ChatModelPickerStore(defaults: UserDefaults(suiteName: "ChatViewModelTests.\(UUID().uuidString)") ??
.standard)
let transport = TestChatTransport(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
commandResponses: commandResponses,
requestHistoryHook: requestHistoryHook,
historyResponseHook: historyResponseHook,
setActiveSessionHook: setActiveSessionHook,
createSessionHook: createSessionHook,
resetSessionHook: resetSessionHook,
compactSessionHook: compactSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook,
renameSessionHook: renameSessionHook,
setSessionPinnedHook: setSessionPinnedHook,
setSessionArchivedHook: setSessionArchivedHook,
listSessionsHook: listSessionsHook,
sendMessageHook: sendMessageHook,
sendMessageStatus: sendMessageStatus,
waitForRunCompletionHook: waitForRunCompletionHook,
healthResponses: healthResponses)
let vm = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
activeAgentId: activeAgentId,
sessionRoutingContract: sessionRoutingContract,
modelPickerStore: pickerStore,
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>?
private var isOpen = false
func wait() async {
guard !self.isOpen else { return }
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func open() {
self.isOpen = true
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 final class WeakReference<Value: AnyObject> {
weak var value: Value?
init(_ value: Value) {
self.value = value
}
}
private func weakReference<Value: AnyObject>(to value: Value?) throws -> WeakReference<Value> {
let value = try #require(value)
return WeakReference(value)
}
struct TestSessionListQuery: Equatable, Sendable {
var limit: Int?
var search: String?
var archived: Bool
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var commandsCallCount: 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 sentAgentIDs: [String?] = []
var sentRoutingContracts: [String?] = []
var sentMessages: [String] = []
var sentRunIds: [String] = []
var commandSessionKeys: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
var waitCompletionRunIds: [String] = []
var patchedModels: [String?] = []
var patchedThinkingLevels: [String] = []
var listSessionsQueries: [TestSessionListQuery] = []
var renamedLabelsByKey: [(key: String, label: String)] = []
var pinnedChanges: [(key: String, pinned: Bool)] = []
var archivedChanges: [(key: String, archived: Bool)] = []
}
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 commandResponses: [[OpenClawChatCommandChoice]]
private let requestHistoryHook: (@Sendable (String) async throws -> Void)?
private let historyResponseHook:
(@Sendable (String, Int, [String]) async throws -> OpenClawChatHistoryPayload?)?
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 renameSessionHook: (@Sendable (String, String) async throws -> Void)?
private let setSessionPinnedHook: (@Sendable (String, Bool) async throws -> Void)?
private let setSessionArchivedHook: (@Sendable (String, Bool) async throws -> Void)?
private let listSessionsHook:
(@Sendable (TestSessionListQuery) async throws -> OpenClawChatSessionsListResponse?)?
private let sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)?
private let sendMessageStatus: String
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]] = [],
commandResponses: [[OpenClawChatCommandChoice]] = [],
requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil,
historyResponseHook: (@Sendable (String, Int, [String]) async throws -> OpenClawChatHistoryPayload?)? = 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,
renameSessionHook: (@Sendable (String, String) async throws -> Void)? = nil,
setSessionPinnedHook: (@Sendable (String, Bool) async throws -> Void)? = nil,
setSessionArchivedHook: (@Sendable (String, Bool) async throws -> Void)? = nil,
listSessionsHook: (
@Sendable (TestSessionListQuery) async throws -> OpenClawChatSessionsListResponse?)? = nil,
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
sendMessageStatus: String = "ok",
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.commandResponses = commandResponses
self.requestHistoryHook = requestHistoryHook
self.historyResponseHook = historyResponseHook
self.setActiveSessionHook = setActiveSessionHook
self.createSessionHook = createSessionHook
self.resetSessionHook = resetSessionHook
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
self.renameSessionHook = renameSessionHook
self.setSessionPinnedHook = setSessionPinnedHook
self.setSessionArchivedHook = setSessionArchivedHook
self.listSessionsHook = listSessionsHook
self.sendMessageHook = sendMessageHook
self.sendMessageStatus = sendMessageStatus
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?,
worktree _: Bool?) 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 state.nextHistoryCallIndex()
if let requestHistoryHook {
try await requestHistoryHook(sessionKey)
}
if let historyResponseHook {
let sentRunIds = await self.sentRunIds()
if let response = try await historyResponseHook(sessionKey, idx, sentRunIds) {
return response
}
}
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,
agentID: String?,
expectedSessionRoutingContract: String?,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
await self.state.sentAgentIDsAppend(agentID)
await self.state.sentRoutingContractsAppend(expectedSessionRoutingContract)
return try await self.sendMessage(
sessionKey: sessionKey,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
await self.state.sentSessionKeysAppend(sessionKey)
await self.state.sentMessagesAppend(message)
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
if let sendMessageHook {
return try await sendMessageHook(idempotencyKey)
}
return OpenClawChatSendResponse(runId: idempotencyKey, status: self.sendMessageStatus)
}
func abortRun(sessionKey _: String, runId: String) async throws {
await self.state.abortedRunIdsAppend(runId)
}
func listSessions(
limit: Int?,
search: String?,
archived: Bool) async throws -> OpenClawChatSessionsListResponse
{
let query = TestSessionListQuery(limit: limit, search: search, archived: archived)
// Single actor hop: bootstrap assertions in older tests race the
// post-health sync, so this fake must not add suspension points.
let idx = await state.recordSessionsCall(query)
if let listSessionsHook, let response = try await listSessionsHook(query) {
return response
}
if idx < self.sessionsResponses.count {
return self.sessionsResponses[idx]
}
return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse(
ts: nil,
path: nil,
count: 0,
defaults: nil,
sessions: [])
}
func patchSession(
key: String,
label: String??,
category _: String??,
pinned: Bool?,
archived: Bool?,
unread _: Bool?) async throws
{
if let label, let label {
await self.state.renamedLabelsAppend(key: key, label: label)
if let renameSessionHook {
try await renameSessionHook(key, label)
}
}
if let pinned {
await self.state.pinnedChangesAppend(key: key, pinned: pinned)
if let setSessionPinnedHook {
try await setSessionPinnedHook(key, pinned)
}
}
if let archived {
await self.state.archivedChangesAppend(key: key, archived: archived)
if let setSessionArchivedHook {
try await setSessionArchivedHook(key, archived)
}
}
}
func listModels() async throws -> [OpenClawChatModelChoice] {
let idx = await state.nextModelsCallIndex()
if idx < self.modelResponses.count {
return self.modelResponses[idx]
}
return self.modelResponses.last ?? []
}
var supportsSlashCommandCatalog: Bool {
!self.commandResponses.isEmpty
}
func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] {
await self.state.commandSessionKeysAppend(sessionKey)
let idx = await state.nextCommandsCallIndex()
if idx < self.commandResponses.count {
return self.commandResponses[idx]
}
return self.commandResponses.last ?? []
}
func setSessionModel(sessionKey _: String, model: String?) async throws {
await self.state.patchedModelsAppend(model)
if let setSessionModelHook {
try await setSessionModelHook(model)
}
}
func resetSession(sessionKey: String) async throws {
await self.state.resetSessionKeysAppend(sessionKey)
if let resetSessionHook {
try await resetSessionHook(sessionKey)
}
}
func compactSession(sessionKey: String) async throws {
await self.state.compactSessionKeysAppend(sessionKey)
if let compactSessionHook {
try await compactSessionHook(sessionKey)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook {
try await setSessionThinkingHook(thinkingLevel)
}
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
let idx = await 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 state.sentRunIds
return ids.last
}
func sentRunIds() async -> [String] {
await self.state.sentRunIds
}
func sentMessages() async -> [String] {
await self.state.sentMessages
}
func sentAgentIDs() async -> [String?] {
await self.state.sentAgentIDs
}
func sentRoutingContracts() async -> [String?] {
await self.state.sentRoutingContracts
}
func commandSessionKeys() async -> [String] {
await self.state.commandSessionKeys
}
func lastSentSessionKey() async -> String? {
let keys = await 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 healthCallCount() async -> Int {
await self.state.healthCallCount
}
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
}
func listSessionsQueries() async -> [TestSessionListQuery] {
await self.state.listSessionsQueries
}
func renamedLabels() async -> [(key: String, label: String)] {
await self.state.renamedLabelsByKey
}
func pinnedChanges() async -> [(key: String, pinned: Bool)] {
await self.state.pinnedChanges
}
func archivedChanges() async -> [(key: String, archived: Bool)] {
await self.state.archivedChanges
}
}
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 recordSessionsCall(_ query: TestSessionListQuery) -> Int {
self.listSessionsQueries.append(query)
return self.nextSessionsCallIndex()
}
fileprivate func nextModelsCallIndex() -> Int {
defer { self.modelsCallCount += 1 }
return self.modelsCallCount
}
fileprivate func nextCommandsCallIndex() -> Int {
defer { self.commandsCallCount += 1 }
return self.commandsCallCount
}
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 commandSessionKeysAppend(_ v: String) {
self.commandSessionKeys.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)
}
fileprivate func sentAgentIDsAppend(_ v: String?) {
self.sentAgentIDs.append(v)
}
fileprivate func sentRoutingContractsAppend(_ v: String?) {
self.sentRoutingContracts.append(v)
}
fileprivate func sentMessagesAppend(_ v: String) {
self.sentMessages.append(v)
}
fileprivate func renamedLabelsAppend(key: String, label: String) {
self.renamedLabelsByKey.append((key: key, label: label))
}
fileprivate func pinnedChangesAppend(key: String, pinned: Bool) {
self.pinnedChanges.append((key: key, pinned: pinned))
}
fileprivate func archivedChangesAppend(key: String, archived: Bool) {
self.archivedChanges.append((key: key, archived: archived))
}
}
@Suite(.serialized)
struct ChatViewModelTests {
@Test func `context usage fraction validates freshness and token bounds`() {
func fraction(total: Int?, fresh: Bool? = true, context: Int?) -> Double? {
OpenClawChatViewModel.chatContextUsageFraction(
for: sessionEntry(
key: "main",
updatedAt: 1,
totalTokens: total,
totalTokensFresh: fresh,
contextTokens: context))
}
#expect(fraction(total: nil, context: 100) == nil)
#expect(fraction(total: 25, context: nil) == nil)
#expect(fraction(total: 25, context: 0) == nil)
#expect(fraction(total: 25, context: -100) == nil)
#expect(fraction(total: -1, context: 100) == nil)
#expect(fraction(total: 25, fresh: false, context: 100) == nil)
#expect(fraction(total: 150, context: 100) == 1)
#expect(fraction(total: 25, fresh: nil, context: 100) == 0.25)
}
@Test @MainActor func `event listener does not retain discarded view model`() async throws {
let transport = TestChatTransport(historyResponses: [historyPayload()])
var viewModel: OpenClawChatViewModel? = OpenClawChatViewModel(
sessionKey: "main",
transport: transport)
transport.emit(.health(ok: true))
for _ in 0..<100 where viewModel?.healthOK != true {
await Task.yield()
}
#expect(viewModel?.healthOK == true)
let discardedViewModel = try weakReference(to: viewModel)
viewModel = nil
await Task.yield()
#expect(discardedViewModel.value == nil)
}
@Test func `decodes in-flight run from chat history`() throws {
let data = #"{"sessionKey":"main","messages":[],"inFlightRun":{"runId":"run-active","text":"partial"}}"#
.data(using: .utf8)!
let payload = try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: data)
#expect(payload.inFlightRun?.runId == "run-active")
#expect(payload.inFlightRun?.text == "partial")
}
@Test func `decodes agent scope from chat event`() throws {
let data = #"{"runId":"run-global","sessionKey":"global","agentId":"work","state":"delta"}"#
.data(using: .utf8)!
let payload = try JSONDecoder().decode(OpenClawChatEventPayload.self, from: data)
#expect(payload.agentId == "work")
}
@Test func `bootstrap adopts active history run and consumes live events`() async throws {
let activeHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "keep working", timestamp: 1)],
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "partial reply"))
let completedHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "keep working", timestamp: 1),
chatTextMessage(role: "assistant", text: "finished reply", timestamp: 2),
])
let (transport, vm) = await makeViewModel(historyResponses: [activeHistory, completedHistory])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.streamingAssistantText } == "partial reply")
emitAssistantText(transport: transport, runId: "run-active", text: "newer partial")
try await waitUntil("adopted run consumes live delta") {
await MainActor.run { vm.streamingAssistantText == "newer partial" }
}
emitExternalFinal(transport: transport, runId: "run-active")
try await waitUntil("adopted run completes") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.streamingAssistantText == nil &&
vm.messages.contains { $0.content.contains { $0.text == "finished reply" } }
}
}
}
@Test func `adopts Codex history run without buffered text`() async throws {
let history = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-codex", text: ""))
let (_, vm) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
#expect(await MainActor.run { !vm.canSend })
}
@Test func `foreground history refreshes adopted run snapshot`() async throws {
let firstHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "first partial"))
let resumedHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "resumed partial"))
let historyCalls = AsyncCounter()
let (_, vm) = await makeViewModel(
historyResponses: [firstHistory, resumedHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() })
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial in-flight snapshot applied") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "first partial"
}
}
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground history requested") {
await historyCalls.current() == 2
}
try await waitUntil("foreground snapshot applied") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "resumed partial"
}
}
}
@Test func `active history retains repeated optimistic user when new row is absent`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let olderUser = chatTextMessage(
role: "user",
text: "repeat request",
timestamp: now - 10000,
idempotencyKey: "older:prompt")
let olderAssistant = chatTextMessage(
role: "assistant",
text: "older reply",
timestamp: now - 9000,
idempotencyKey: "older:assistant")
let existingHistory = historyPayload(messages: [olderUser, olderAssistant])
let (_, vm) = await makeViewModel(
historyResponses: [existingHistory],
historyResponseHook: { _, index, sentRunIds in
guard index > 0, let runId = sentRunIds.last else { return nil }
return historyPayload(
messages: [olderUser, olderAssistant],
inFlightRun: OpenClawChatInFlightRun(runId: runId, text: "working"))
},
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "repeat request")
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
try await waitUntil("repeated optimistic user survives incomplete active history") {
await MainActor.run {
vm.pendingRunCount == 1 &&
vm.messages.count(where: {
$0.role == "user" && $0.content.first?.text == "repeat request"
}) == 2 &&
vm.messages.contains(where: { $0.id == optimisticID })
}
}
}
@Test func `foreground discovers run started without local ownership`() async throws {
let idleHistory = historyPayload()
let activeHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-external", text: "external partial"))
let (_, vm) = await makeViewModel(historyResponses: [idleHistory, activeHistory])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.pendingRunCount } == 0)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground external run discovered") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "external partial"
}
}
}
@Test func `foreground keeps active run with intermediate assistant history`() async throws {
let idleHistory = historyPayload()
let activeHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "use a tool", timestamp: 1),
chatTextMessage(role: "assistant", text: "intermediate output", timestamp: 2),
],
inFlightRun: OpenClawChatInFlightRun(runId: "run-tool", text: "still working"))
let (_, vm) = await makeViewModel(historyResponses: [idleHistory, activeHistory])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground tool run discovered") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "still working"
}
}
#expect(await MainActor.run { !vm.canSend })
}
@Test func `foreground clears completed run without assistant output`() async throws {
let activeHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: 1)],
inFlightRun: OpenClawChatInFlightRun(runId: "run-quiet", text: ""))
let completedHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: 1)])
let (_, vm) = await makeViewModel(historyResponses: [activeHistory, completedHistory])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.pendingRunCount == 1 })
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("silent completed run clears") {
await MainActor.run { vm.pendingRunCount == 0 }
}
}
@Test func `foreground missing snapshot does not clear an in-flight send`() async throws {
let sendGate = AsyncGate()
let historyCalls = AsyncCounter()
let activeHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "accepted"))
let (_, vm) = await makeViewModel(
historyResponses: [historyPayload(), historyPayload(), activeHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
sendMessageHook: { _ in
await sendGate.wait()
return OpenClawChatSendResponse(runId: "run-active", status: "pending")
})
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "send while resuming")
try await waitUntil("send request is in flight") {
await MainActor.run { vm.isSending && vm.pendingRunCount == 1 }
}
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground history applies") { await historyCalls.current() == 2 }
#expect(await MainActor.run { vm.pendingRunCount == 1 })
await sendGate.open()
try await waitUntil("accepted run snapshot applies") {
await MainActor.run {
!vm.isSending && vm.pendingRunCount == 1 && vm.streamingAssistantText == "accepted"
}
}
}
@Test func `post-send history keeps active run with intermediate assistant output`() async throws {
let activeHistory = historyPayload(
messages: [chatTextMessage(role: "assistant", text: "intermediate", timestamp: 2)],
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "working"))
let (_, vm) = await makeViewModel(
historyResponses: [historyPayload(), activeHistory],
sendMessageHook: { _ in
OpenClawChatSendResponse(runId: "run-active", status: "pending")
})
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "do work")
try await waitUntil("post-send active run remains pending") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "working"
}
}
}
@Test func `legacy history omission does not clear pending run`() async throws {
let legacyHistory = historyPayload(supportsActiveRunState: false)
let (_, vm) = await makeViewModel(
historyResponses: [legacyHistory, legacyHistory, legacyHistory],
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "legacy gateway")
try await waitUntil("legacy send remains pending") {
await MainActor.run { !vm.isSending && vm.pendingRunCount == 1 }
}
try await Task.sleep(for: .milliseconds(1700))
#expect(await MainActor.run { vm.pendingRunCount == 1 })
}
@Test func `external delta does not replace owned run`() async throws {
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "local work")
let runId = try await waitForLastSentRunId(transport)
try await waitUntil("local run owned") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "delta",
message: chatTextMessage(role: "assistant", text: "other output", timestamp: 2),
errorMessage: nil)))
try await Task.sleep(for: .milliseconds(50))
#expect(await MainActor.run { vm.streamingAssistantText == nil })
emitAssistantText(transport: transport, runId: runId, text: "local output")
try await waitUntil("owned run still consumes deltas") {
await MainActor.run { vm.streamingAssistantText == "local output" }
}
}
@Test func `live chat delta owns run while bootstrap history is pending`() async throws {
let historyGate = AsyncGate()
let historyCalls = AsyncCounter()
let staleHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "older", timestamp: 1),
chatTextMessage(role: "assistant", text: "same reply", timestamp: 2),
chatTextMessage(role: "user", text: "current", timestamp: 3),
],
inFlightRun: OpenClawChatInFlightRun(runId: "run-stale", text: "stale partial"))
let (transport, vm) = await makeViewModel(
historyResponses: [staleHistory],
requestHistoryHook: { _ in
_ = await historyCalls.increment()
await historyGate.wait()
})
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history starts") { await historyCalls.current() == 1 }
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-live",
sessionKey: "main",
state: "delta",
message: chatTextMessage(
role: "assistant",
text: "live partial",
timestamp: 1),
errorMessage: nil)))
try await waitUntil("live delta owns run") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "live partial"
}
}
await historyGate.open()
try await waitUntil("bootstrap completes") { await MainActor.run { vm.healthOK } }
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.streamingAssistantText } == "live partial")
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-live",
sessionKey: "main",
state: "final",
message: chatTextMessage(role: "assistant", text: "same reply", timestamp: 4),
errorMessage: nil)))
try await waitUntil("live final remains scoped to current user") {
await MainActor.run { vm.messages.count { $0.content.first?.text == "same reply" } == 2 }
}
}
@Test func `global chat delta adopts only selected agent run`() async throws {
let bareGlobalMatches = await MainActor.run {
(
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
agentId: "work",
current: "global",
mainSessionKey: "main",
activeAgentId: "main"),
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
agentId: "main",
current: "global",
mainSessionKey: "main",
activeAgentId: "main"))
}
#expect(!bareGlobalMatches.0)
#expect(bareGlobalMatches.1)
#expect(await MainActor.run {
!OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
agentId: "work",
current: "global",
mainSessionKey: "main")
})
#expect(await MainActor.run {
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
current: "global",
mainSessionKey: "main",
activeAgentId: "main")
})
#expect(await MainActor.run {
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
current: "global",
mainSessionKey: "main",
activeAgentId: "work")
})
let globalAliasMatches = await MainActor.run {
(
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
agentId: "work",
current: "main",
mainSessionKey: "global",
activeAgentId: "main"),
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
agentId: "main",
current: "main",
mainSessionKey: "global",
activeAgentId: "main"))
}
#expect(!globalAliasMatches.0)
#expect(globalAliasMatches.1)
#expect(await MainActor.run {
!OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "main",
agentId: "work",
current: "global",
mainSessionKey: "global",
activeAgentId: "main")
})
#expect(await MainActor.run {
!OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "global",
current: "global",
mainSessionKey: "main")
})
let (transport, vm) = await makeViewModel(
sessionKey: "global",
activeAgentId: "work",
historyResponses: [historyPayload(sessionKey: "global")])
try await loadAndWaitBootstrap(vm: vm)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-other",
sessionKey: "global",
agentId: "main",
state: "delta",
message: chatTextMessage(role: "assistant", text: "wrong agent", timestamp: 1),
errorMessage: nil)))
try await Task.sleep(for: .milliseconds(50))
#expect(await MainActor.run { vm.pendingRunCount } == 0)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-work",
sessionKey: "global",
agentId: "work",
state: "delta",
message: chatTextMessage(role: "assistant", text: "selected agent", timestamp: 2),
errorMessage: nil)))
try await waitUntil("selected global run adopted") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "selected agent"
}
}
let (lateTransport, lateVM) = await makeViewModel(
sessionKey: "global",
historyResponses: [historyPayload(sessionKey: "global")])
try await loadAndWaitBootstrap(vm: lateVM)
lateTransport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-late",
sessionKey: "global",
agentId: "work",
state: "delta",
message: chatTextMessage(role: "assistant", text: "late identity", timestamp: 3),
errorMessage: nil)))
try await Task.sleep(for: .milliseconds(50))
#expect(await MainActor.run { lateVM.pendingRunCount == 0 })
await MainActor.run { lateVM.syncActiveAgentId("work") }
lateTransport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-late",
sessionKey: "global",
agentId: "work",
state: "delta",
message: chatTextMessage(role: "assistant", text: "late identity", timestamp: 3),
errorMessage: nil)))
try await waitUntil("late global agent identity adopts run") {
await MainActor.run { lateVM.pendingRunCount == 1 }
}
}
@Test func `global agent switch clears previous run ownership`() async throws {
let (transport, vm) = await makeViewModel(
sessionKey: "global",
activeAgentId: "main",
historyResponses: [
historyPayload(sessionKey: "global"),
historyPayload(sessionKey: "global"),
])
try await loadAndWaitBootstrap(vm: vm)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-main",
sessionKey: "global",
agentId: "main",
state: "delta",
message: chatTextMessage(role: "assistant", text: "old partial", timestamp: 1),
errorMessage: nil)))
try await waitUntil("main run becomes pending") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "old partial"
}
}
await MainActor.run { vm.syncActiveAgentId("work") }
#expect(await MainActor.run { vm.pendingRunCount } == 0)
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
#expect(await MainActor.run { vm.messages.isEmpty })
}
@Test func `live send binds the captured agent and routing contract`() async throws {
let contract = "per-sender|main|reviewer"
let (transport, vm) = await makeViewModel(
activeAgentId: "reviewer",
historyResponses: [historyPayload(), historyPayload()],
sessionRoutingContract: contract)
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "route safely")
_ = try await waitForLastSentRunId(transport)
#expect(await transport.sentAgentIDs() == ["reviewer"])
#expect(await transport.sentRoutingContracts() == [contract])
}
@Test func `alias routing contract change restarts bootstrap`() async throws {
let historyCalls = AsyncCounter()
let oldHistory = historyPayload(messages: [
chatTextMessage(role: "assistant", text: "old route", timestamp: 1),
])
let newHistory = historyPayload(messages: [
chatTextMessage(role: "assistant", text: "new route", timestamp: 2),
])
let (_, vm) = await makeViewModel(
activeAgentId: "main",
historyResponses: [oldHistory, newHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() })
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial route history") {
await MainActor.run { vm.messages.first?.content.first?.text == "old route" }
}
await MainActor.run {
vm.syncDeliveryIdentity(
activeAgentId: "work",
sessionRoutingContract: "per-sender|work-main|work")
}
try await waitUntil("replacement route history") {
guard await historyCalls.current() == 2 else { return false }
return await MainActor.run { vm.messages.first?.content.first?.text == "new route" }
}
}
@Test func `custom main routing contract change restarts bootstrap`() async throws {
let historyCalls = AsyncCounter()
let oldHistory = historyPayload(
sessionKey: "agent:ops:work",
messages: [chatTextMessage(role: "assistant", text: "old scope", timestamp: 1)])
let newHistory = historyPayload(
sessionKey: "agent:ops:work",
messages: [chatTextMessage(role: "assistant", text: "new scope", timestamp: 2)])
let (_, vm) = await makeViewModel(
sessionKey: "agent:ops:work",
activeAgentId: "ops",
historyResponses: [oldHistory, newHistory],
sessionRoutingContract: "global|work|ops",
requestHistoryHook: { _ in _ = await historyCalls.increment() })
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial custom main history") {
await MainActor.run { vm.messages.first?.content.first?.text == "old scope" }
}
await MainActor.run {
vm.syncSessionRoutingContract("per-sender|work|ops")
}
try await waitUntil("replacement custom main history") {
guard await historyCalls.current() == 2 else { return false }
return await MainActor.run { vm.messages.first?.content.first?.text == "new scope" }
}
}
@Test func `unscoped agent update replaces an active bootstrap`() async throws {
let firstHistoryGate = AsyncGate()
let historyCalls = AsyncCounter()
let firstHistory = historyPayload(
sessionKey: "Matrix:!Room:example.org",
messages: [chatTextMessage(role: "assistant", text: "old agent", timestamp: 1)])
let replacementHistory = historyPayload(
sessionKey: "Matrix:!Room:example.org",
messages: [chatTextMessage(role: "assistant", text: "new agent", timestamp: 2)])
let (_, vm) = await makeViewModel(
sessionKey: "Matrix:!Room:example.org",
historyResponses: [firstHistory, replacementHistory],
requestHistoryHook: { _ in
let call = await historyCalls.increment()
if call == 1 { await firstHistoryGate.wait() }
})
await MainActor.run { vm.load() }
try await waitUntil("first unscoped bootstrap") { await historyCalls.current() == 1 }
await MainActor.run { vm.syncActiveAgentId("work") }
try await waitUntil("replacement unscoped bootstrap") {
guard await historyCalls.current() == 2 else { return false }
return await MainActor.run {
!vm.isLoading && vm.messages.first?.content.first?.text == "new agent"
}
}
await firstHistoryGate.open()
try await Task.sleep(for: .milliseconds(25))
#expect(await MainActor.run { vm.messages.first?.content.first?.text } == "new agent")
}
@Test func `intermediate session message preserves pending recovery snapshot`() async throws {
let historyGate = AsyncGate()
let historyCalls = AsyncCounter()
let activeHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "active partial"))
let (transport, vm) = await makeViewModel(
historyResponses: [activeHistory],
requestHistoryHook: { _ in
_ = await historyCalls.increment()
await historyGate.wait()
})
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history starts") { await historyCalls.current() == 1 }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "main",
message: chatTextModelMessage(
role: "assistant",
text: "intermediate output",
timestamp: 1),
messageId: "msg-intermediate",
messageSeq: 1)))
try await waitUntil("intermediate assistant message applies") {
await MainActor.run { vm.messages.contains { $0.content.first?.text == "intermediate output" } }
}
await historyGate.open()
try await waitUntil("bootstrap active run adopted") {
await MainActor.run {
vm.pendingRunCount == 1 &&
vm.streamingAssistantText == "active partial" &&
vm.messages.contains { $0.content.first?.text == "intermediate output" }
}
}
}
@Test func `manual refresh re-adopts active run after clearing local ownership`() async throws {
let firstHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "first partial"))
let refreshedHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "refreshed partial"))
let (_, vm) = await makeViewModel(historyResponses: [firstHistory, refreshedHistory])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.refresh() }
try await waitUntil("manual refresh snapshot applied") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "refreshed partial"
}
}
}
@Test func `older history cannot replace newer run snapshot`() async throws {
let olderGate = AsyncGate()
let historyCalls = AsyncCounter()
let olderCompletions = AsyncCounter()
let initialHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-initial", text: "initial"))
let (_, vm) = await makeViewModel(
historyResponses: [initialHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
historyResponseHook: { _, index, _ in
if index == 1 {
await olderGate.wait()
_ = await olderCompletions.increment()
return historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-older", text: "older"))
}
if index == 2 {
return historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-newer", text: "newer"))
}
return nil
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("older foreground history starts") { await historyCalls.current() == 2 }
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("newer run snapshot applies") {
await MainActor.run { vm.streamingAssistantText == "newer" }
}
await olderGate.open()
try await waitUntil("older foreground history completes") { await olderCompletions.current() == 1 }
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.streamingAssistantText } == "newer")
}
@Test func `delayed history cannot overwrite newer live run text`() async throws {
let staleGate = AsyncGate()
let historyCalls = AsyncCounter()
let staleCompletions = AsyncCounter()
let activeHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "initial"))
let (transport, vm) = await makeViewModel(
historyResponses: [activeHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
historyResponseHook: { _, index, _ in
guard index == 1 else { return nil }
await staleGate.wait()
_ = await staleCompletions.increment()
return historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "stale"))
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("stale foreground history starts") { await historyCalls.current() == 2 }
emitAssistantText(transport: transport, runId: "run-active", text: "live newer")
try await waitUntil("newer live run text applies") {
await MainActor.run { vm.streamingAssistantText == "live newer" }
}
await staleGate.open()
try await waitUntil("stale foreground history completes") { await staleCompletions.current() == 1 }
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.streamingAssistantText } == "live newer")
}
@Test func `stale foreground completion cannot clear newer live run`() async throws {
let staleGate = AsyncGate()
let historyCalls = AsyncCounter()
let activeHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "keep going", timestamp: 1)],
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "initial"))
let staleCompletedHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "keep going", timestamp: 1),
chatTextMessage(role: "assistant", text: "stale completion", timestamp: 2),
])
let (transport, vm) = await makeViewModel(
historyResponses: [activeHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
historyResponseHook: { _, index, _ in
guard index == 1 else { return nil }
await staleGate.wait()
return staleCompletedHistory
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("stale foreground history starts") { await historyCalls.current() == 2 }
emitAssistantText(transport: transport, runId: "run-active", text: "live newer")
try await waitUntil("newer live run text applies") {
await MainActor.run { vm.streamingAssistantText == "live newer" }
}
await staleGate.open()
try await waitUntil("stale history transcript applies") {
await MainActor.run { vm.messages.contains { $0.content.first?.text == "stale completion" } }
}
#expect(await MainActor.run { vm.pendingRunCount } == 1)
#expect(await MainActor.run { vm.streamingAssistantText } == "live newer")
}
@Test func `terminal event invalidates delayed active run snapshot`() async throws {
let staleGate = AsyncGate()
let historyCalls = AsyncCounter()
let staleCompletions = AsyncCounter()
let activeHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "working"))
let completedHistory = historyPayload(
messages: [chatTextMessage(role: "assistant", text: "done", timestamp: 2)])
let (transport, vm) = await makeViewModel(
historyResponses: [activeHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
historyResponseHook: { _, index, _ in
if index == 1 {
await staleGate.wait()
_ = await staleCompletions.increment()
return activeHistory
}
return index == 2 ? completedHistory : nil
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("stale foreground history starts") { await historyCalls.current() == 2 }
emitExternalFinal(transport: transport, runId: "run-active")
try await waitUntil("terminal history applies") {
await MainActor.run {
vm.pendingRunCount == 0 && vm.messages.contains { $0.content.contains { $0.text == "done" } }
}
}
await staleGate.open()
try await waitUntil("stale foreground history completes") { await staleCompletions.current() == 1 }
#expect(await MainActor.run { vm.pendingRunCount } == 0)
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
}
@Test func `delayed history cannot erase terminal event message`() async throws {
let staleGate = AsyncGate()
let historyCalls = AsyncCounter()
let staleCompletions = AsyncCounter()
let activeHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "older turn", timestamp: 0),
chatTextMessage(role: "assistant", text: "live final", timestamp: 0.5),
chatTextMessage(role: "user", text: "finish this", timestamp: 1),
],
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "working"))
let (transport, vm) = await makeViewModel(
historyResponses: [activeHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
historyResponseHook: { _, index, _ in
if index == 1 {
await staleGate.wait()
_ = await staleCompletions.increment()
return historyPayload(
messages: [
chatTextMessage(role: "user", text: "older turn", timestamp: 0),
chatTextMessage(role: "assistant", text: "live final", timestamp: 0.5),
chatTextMessage(role: "user", text: "finish this", timestamp: 1),
],
inFlightRun: OpenClawChatInFlightRun(runId: "run-active", text: "stale"))
}
if index == 2 {
throw NSError(domain: "test", code: 1)
}
return nil
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("stale foreground history starts") { await historyCalls.current() == 2 }
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "run-active",
sessionKey: "main",
state: "final",
message: chatTextMessage(role: "assistant", text: "live final", timestamp: 2),
errorMessage: nil)))
try await waitUntil("terminal event message appears") {
await MainActor.run { vm.messages.count { $0.content.first?.text == "live final" } == 2 }
}
try await waitUntil("terminal refresh attempted") { await historyCalls.current() == 3 }
await staleGate.open()
try await waitUntil("stale foreground history completes") { await staleCompletions.current() == 1 }
#expect(await MainActor.run { vm.messages.count { $0.content.first?.text == "live final" } == 2 })
#expect(await MainActor.run { vm.pendingRunCount } == 0)
}
@Test func `external terminal event protects current run from delayed snapshot`() async throws {
let staleGate = AsyncGate()
let historyCalls = AsyncCounter()
let staleCompletions = AsyncCounter()
let currentHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-current", text: "current"))
let (transport, vm) = await makeViewModel(
historyResponses: [currentHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
historyResponseHook: { _, index, _ in
if index == 1 {
await staleGate.wait()
_ = await staleCompletions.increment()
return historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-finished", text: "stale"))
}
if index == 2 {
throw NSError(domain: "test", code: 1)
}
return nil
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("stale foreground history starts") { await historyCalls.current() == 2 }
emitExternalFinal(transport: transport, runId: "run-finished")
try await waitUntil("terminal refresh attempted") { await historyCalls.current() == 3 }
await staleGate.open()
try await waitUntil("stale foreground history completes") { await staleCompletions.current() == 1 }
emitAssistantText(transport: transport, runId: "run-current", text: "current live")
try await waitUntil("current run still owns live events") {
await MainActor.run { vm.streamingAssistantText == "current live" }
}
#expect(await MainActor.run { vm.pendingRunCount } == 1)
}
@Test func `sequence gap re-adopts active run from history`() async throws {
let initialHistory = historyPayload()
let recoveredHistory = historyPayload(
inFlightRun: OpenClawChatInFlightRun(runId: "run-recovered", text: "recovered partial"))
let (transport, vm) = await makeViewModel(historyResponses: [initialHistory, recoveredHistory])
try await loadAndWaitBootstrap(vm: vm)
transport.emit(.seqGap)
try await waitUntil("sequence gap run recovered") {
await MainActor.run {
vm.pendingRunCount == 1 && vm.streamingAssistantText == "recovered partial"
}
}
}
@Test func `keeps distinct idempotent user turns with identical timestamps and content`() async throws {
let history = historyPayload(
messages: [
chatTextMessage(
role: "user",
text: "same words",
timestamp: 1,
idempotencyKey: "client-a:user"),
chatTextMessage(
role: "user",
text: "same words",
timestamp: 1,
idempotencyKey: "client-b:user"),
])
let (_, vm) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.messages.count } == 2)
}
@Test func `timeline revision advances when visible history changes`() async throws {
let history = historyPayload(
sessionId: "revision-session",
messages: [chatTextMessage(role: "user", text: "hello", timestamp: 1)])
let (_, vm) = await makeViewModel(historyResponses: [history])
let before = await MainActor.run { vm.timelineRevision }
try await loadAndWaitBootstrap(vm: vm, sessionId: "revision-session")
let after = await MainActor.run { vm.timelineRevision }
#expect(after > before)
}
@Test func `timeline revision ignores identical history refresh`() async throws {
let message = chatTextMessage(role: "user", text: "hello", timestamp: 1)
let firstHistory = historyPayload(sessionId: "revision-session-1", messages: [message])
let secondHistory = historyPayload(sessionId: "revision-session-2", messages: [message])
let (_, vm) = await makeViewModel(historyResponses: [firstHistory, secondHistory])
try await loadAndWaitBootstrap(vm: vm, sessionId: "revision-session-1")
let before = await MainActor.run { vm.timelineRevision }
await MainActor.run { vm.refresh() }
try await waitUntil("identical history refresh") {
await MainActor.run { vm.sessionId == "revision-session-2" }
}
let after = await MainActor.run { vm.timelineRevision }
#expect(after == before)
}
@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],
sendMessageStatus: "pending")
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],
sendMessageStatus: "pending")
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 `duplicate final events append one provisional reply`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello")
let runId = try await waitForLastSentRunId(transport)
let final = OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "one reply",
timestamp: Date().timeIntervalSince1970 * 1000),
errorMessage: nil)
transport.emit(.chat(final))
transport.emit(.chat(final))
try await Task.sleep(nanoseconds: 50_000_000)
#expect(await MainActor.run {
vm.messages.count(where: { message in
message.role == "assistant" && message.content.first?.text == "one reply"
}) == 1
})
}
@Test func `provider canonical history adopts provisional final reply`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(sessionId: sessionId)],
historyResponseHook: { _, index, sentRunIds in
guard index > 0, let runId = sentRunIds.last else { return nil }
if index == 1 {
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "provider-bound request",
timestamp: now + 1,
idempotencyKey: "\(runId):user"),
],
inFlightRun: OpenClawChatInFlightRun(runId: runId, text: "working"))
}
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "provider-bound request",
timestamp: now + 1,
idempotencyKey: "\(runId):user"),
chatTextMessage(
role: "assistant",
text: "provider-bound reply",
timestamp: now + 2,
idempotencyKey: "provider-session:assistant"),
])
},
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "provider-bound request")
let runId = try await waitForLastSentRunId(transport)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "provider-bound reply",
timestamp: now),
errorMessage: nil)))
try await waitUntil("provider canonical history replaces provisional reply") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.count(where: { message in
message.role == "assistant" && message.content.first?.text == "provider-bound reply"
}) == 1 &&
vm.messages.contains(where: { $0.idempotencyKey == "provider-session:assistant" })
}
}
}
@Test func `incomplete history cannot adopt older identical reply as provisional final`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let olderHistory = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "older request",
timestamp: now - 2000,
idempotencyKey: "older:user"),
chatTextMessage(
role: "assistant",
text: "same reply",
timestamp: now - 1000,
idempotencyKey: "older:assistant"),
])
let (transport, vm) = await makeViewModel(
historyResponses: [olderHistory],
historyResponseHook: { _, index, _ in index > 0 ? olderHistory : nil },
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "current request")
let runId = try await waitForLastSentRunId(transport)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "same reply",
timestamp: now),
errorMessage: nil)))
try await waitUntil("incomplete history retains the current turn and provisional final") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.count(where: {
$0.role == "assistant" && $0.content.first?.text == "same reply"
}) == 2 &&
vm.messages.contains(where: {
$0.role == "user" && $0.content.first?.text == "current request"
})
}
}
}
@Test func `session message adopts provisional final event reply`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let finalRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
requestHistoryHook: { _ in
let count = await historyCount.increment()
if count == 2 {
await finalRefreshGate.wait()
}
},
sendMessageStatus: "pending")
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: "dedupe me",
timestamp: now + 1,
contentId: "live-final-content"),
errorMessage: nil)))
try await waitUntil("provisional final visible once") {
await MainActor.run {
vm.messages.count(where: { msg in
msg.role == "assistant" && msg.content.first?.text == "dedupe me"
}) == 1
}
}
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(role: "assistant", text: "dedupe me", timestamp: now + 2),
messageId: "msg-assistant-final",
messageSeq: 2)))
try await waitUntil("canonical session message adopted final event row") {
await MainActor.run {
let matches = vm.messages.filter { msg in
msg.role == "assistant" && msg.content.first?.text == "dedupe me"
}
return matches.count == 1 && matches.first?.timestamp == now + 2
}
}
await finalRefreshGate.release()
}
@Test func `final event does not duplicate canonical assistant session message`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let finalRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
requestHistoryHook: { _ in
let count = await historyCount.increment()
if count == 2 {
await finalRefreshGate.wait()
}
},
sendMessageStatus: "pending")
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(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(role: "assistant", text: "canonical first", timestamp: now + 2),
messageId: "msg-assistant-first",
messageSeq: 2)))
try await waitUntil("canonical assistant visible once") {
await MainActor.run {
vm.messages.count(where: { msg in
msg.role == "assistant" && msg.content.first?.text == "canonical first"
}) == 1
}
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: chatTextMessage(role: "assistant", text: "canonical first", timestamp: now + 1),
errorMessage: nil)))
try await Task.sleep(nanoseconds: 50_000_000)
#expect(await MainActor.run {
let matches = vm.messages.filter { msg in
msg.role == "assistant" && msg.content.first?.text == "canonical first"
}
return matches.count == 1 && matches.first?.timestamp == now + 2
})
await finalRefreshGate.release()
}
@Test func `later identical session reply does not adopt prior turn provisional final`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
sendMessageHook: { runId in
OpenClawChatSendResponse(runId: runId, status: "pending")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "first turn")
try await waitUntil("first pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let firstRunId = try await waitForLastSentRunId(transport)
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: firstRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(role: "assistant", text: "OK", timestamp: now + 1),
errorMessage: nil)))
try await waitUntil("first provisional final visible") {
await MainActor.run {
vm.messages.count(where: { msg in
msg.role == "assistant" && msg.content.first?.text == "OK"
}) == 1
}
}
await sendUserMessage(vm, text: "second turn")
try await waitUntil("second pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(role: "assistant", text: "OK", timestamp: now + 4),
messageId: "msg-second-assistant",
messageSeq: 4)))
try await waitUntil("second identical reply appends after second user") {
await MainActor.run {
let okReplies = vm.messages.filter { msg in
msg.role == "assistant" && msg.content.first?.text == "OK"
}
return okReplies.count == 2 && vm.messages.last?.timestamp == now + 4
}
}
}
@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],
sendMessageStatus: "pending",
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],
sendMessageStatus: "pending")
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],
sendMessageStatus: "pending")
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 `terminal ok send ack clears pending run without waiting for completion`() 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: "cached")
try await waitUntil("terminal ok ack clears pending run") {
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
}
#expect(await MainActor.run { vm.errorText } == nil)
#expect(await transport.waitCompletionRunIds().isEmpty)
#expect(await MainActor.run { vm.messages.containsUserText("cached") })
}
@Test func `rekeys optimistic user message when gateway reuses active run`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let responseGate = AsyncGate()
let (_, vm) = await makeViewModel(
historyResponses: [historyPayload(sessionId: sessionId)],
historyResponseHook: { _, index, _ in
guard index == 1 else { return nil }
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same active request",
timestamp: now + 5000,
idempotencyKey: "\(remoteRunId):user"),
])
},
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
await responseGate.open()
try await waitUntil("reused run adopts one canonical user row") {
await MainActor.run {
vm.messages.count(where: { $0.role == "user" }) == 1 &&
vm.messages.contains(where: { message in
message.id == optimisticID &&
message.timestamp == now + 5000 &&
message.idempotencyKey == "\(remoteRunId):user"
})
}
}
}
@Test func `reused run preserves canonical event received before acknowledgement`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let responseGate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionId: sessionId),
historyPayload(sessionId: sessionId, messages: []),
],
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
let canonicalTimestamp = now + 5000
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "canonical active request",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: canonicalTimestamp,
idempotencyKey: "\(remoteRunId):user"),
messageId: "srv-reused-run-user",
messageSeq: 1)))
try await waitUntil("canonical event arrives before send acknowledgement") {
await MainActor.run { vm.messages.count(where: { $0.role == "user" }) == 2 }
}
await responseGate.open()
try await waitUntil("reused run preserves canonical event data") {
await MainActor.run {
vm.messages.count(where: { $0.role == "user" }) == 1 &&
vm.messages.contains(where: { message in
message.id == optimisticID &&
message.content.first?.text == "canonical active request" &&
message.timestamp == canonicalTimestamp &&
message.idempotencyKey == "\(remoteRunId):user"
})
}
}
}
@Test func `reused run final stays scoped to surviving canonical user turn`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let activeUser = chatTextMessage(
role: "user",
text: "same active request",
timestamp: now + 1,
idempotencyKey: "\(remoteRunId):user")
let activeReply = chatTextMessage(
role: "assistant",
text: "active reply",
timestamp: now + 2,
idempotencyKey: remoteRunId)
let newerUser = chatTextMessage(
role: "user",
text: "newer request from another client",
timestamp: now + 3,
idempotencyKey: "other-client-run:user")
let initialHistory = historyPayload(sessionId: sessionId, messages: [activeUser])
let canonicalHistory = historyPayload(
sessionId: sessionId,
messages: [activeUser, activeReply, newerUser])
let responseGate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [initialHistory, canonicalHistory, canonicalHistory],
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "assistant",
text: "active reply",
timestamp: now + 2,
idempotencyKey: remoteRunId),
messageId: "srv-active-reply",
messageSeq: 2)))
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "user",
text: "newer request from another client",
timestamp: now + 3,
idempotencyKey: "other-client-run:user"),
messageId: "srv-newer-user",
messageSeq: 3)))
try await waitUntil("newer user arrives before reused-run acknowledgement") {
await MainActor.run { vm.messages.containsUserText("newer request from another client") }
}
await responseGate.open()
try await waitUntil("reused run collapses onto earlier canonical user") {
await MainActor.run {
vm.messages.count(where: { $0.role == "user" && $0.content.first?.text == "same active request" }) == 1
}
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: activeReply,
errorMessage: nil)))
try await waitUntil("reused final does not duplicate earlier canonical reply") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages
.count(where: { $0.role == "assistant" && $0.content.first?.text == "active reply" }) == 1
}
}
}
@Test func `newer identical reply does not suppress reused run final`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let responseGate = AsyncGate()
let finalRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let history = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same active request",
timestamp: now - 3000,
idempotencyKey: "\(remoteRunId):user"),
chatTextMessage(
role: "user",
text: "newer request from another client",
timestamp: now - 2000,
idempotencyKey: "other-client-run:user"),
chatTextMessage(
role: "assistant",
text: "OK",
timestamp: now - 1000),
])
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
requestHistoryHook: { _ in
let count = await historyCount.increment()
if count == 3 {
await finalRefreshGate.wait()
}
},
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
try await waitUntil("duplicate request is optimistic") {
await MainActor.run {
vm.messages.count(where: { $0.role == "user" && $0.content.first?.text == "same active request" }) == 2
}
}
await responseGate.open()
try await waitUntil("duplicate request collapses onto older active user") {
guard await historyCount.current() >= 2 else { return false }
return await MainActor.run {
vm.messages.count(where: {
$0.role == "user" && $0.content.first?.text == "same active request"
}) == 1
}
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "OK",
timestamp: now + 4,
idempotencyKey: remoteRunId),
errorMessage: nil)))
try await waitUntil("reused final remains distinct from newer turn reply") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.count(where: { $0.role == "assistant" && $0.content.first?.text == "OK" }) == 2
}
}
await finalRefreshGate.release()
}
@Test func `correlated reply after metadata free steering suppresses reused final duplicate`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let responseGate = AsyncGate()
let history = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same active request",
timestamp: now - 3000,
idempotencyKey: "\(remoteRunId):user"),
chatTextMessage(
role: "user",
text: "steer the active run",
timestamp: now - 2000),
chatTextMessage(
role: "assistant",
text: "steered reply",
timestamp: now - 1000,
idempotencyKey: remoteRunId),
])
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
try await waitUntil("steered duplicate request is optimistic") {
await MainActor.run {
vm.messages.count(where: { $0.role == "user" && $0.content.first?.text == "same active request" }) == 2
}
}
await responseGate.open()
try await waitUntil("steered duplicate request collapses onto active user") {
await MainActor.run {
vm.messages.count(where: {
$0.role == "user" && $0.content.first?.text == "same active request"
}) == 1
}
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "steered reply",
timestamp: now + 1,
idempotencyKey: remoteRunId),
errorMessage: nil)))
try await waitUntil("steering row remains inside reused run scope") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.count(where: {
$0.role == "assistant" && $0.content.first?.text == "steered reply"
}) == 1
}
}
}
@Test func `canonical projected reply after steering adopts reused provisional final`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let finalRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let history = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same active request",
timestamp: now - 1000,
idempotencyKey: "\(remoteRunId):user"),
])
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
requestHistoryHook: { _ in
let count = await historyCount.increment()
if count == 3 {
await finalRefreshGate.wait()
}
},
sendMessageHook: { _ in
OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
try await waitUntil("steering adoption send refresh completes") {
await historyCount.current() >= 2
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "steered reply",
timestamp: now + 1,
idempotencyKey: remoteRunId),
errorMessage: nil)))
try await waitUntil("provisional reply precedes steering row") {
await MainActor.run {
vm.messages.contains { $0.role == "assistant" && $0.content.first?.text == "steered reply" }
}
}
let provisionalID = try await MainActor.run {
try #require(vm.messages.first(where: {
$0.role == "assistant" && $0.content.first?.text == "steered reply"
})?.id)
}
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "user",
text: "steer the active run",
timestamp: now + 2),
messageId: "srv-steering-user",
messageSeq: 2)))
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "assistant",
text: "canonical steered reply",
timestamp: now + 3,
idempotencyKey: remoteRunId),
messageId: "srv-steered-reply",
messageSeq: 3)))
try await waitUntil("canonical reply after steering adopts provisional row") {
await MainActor.run {
guard vm.messages.count(where: { $0.role == "assistant" }) == 1,
let reply = vm.messages.first(where: { $0.id == provisionalID }),
reply.content.first?.text == "canonical steered reply",
reply.timestamp == now + 3,
let steeringIndex = vm.messages.firstIndex(where: {
$0.role == "user" && $0.content.first?.text == "steer the active run"
}),
let replyIndex = vm.messages.firstIndex(where: { $0.id == provisionalID })
else {
return false
}
return steeringIndex < replyIndex
}
}
await finalRefreshGate.release()
}
@Test func `metadata free channel turn does not adopt reused provisional final`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let finalRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let history = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same active request",
timestamp: now - 1000,
idempotencyKey: "\(remoteRunId):user"),
])
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
requestHistoryHook: { _ in
let count = await historyCount.increment()
if count == 3 {
await finalRefreshGate.wait()
}
},
sendMessageHook: { _ in
OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
try await waitUntil("channel boundary send refresh completes") {
await historyCount.current() >= 2
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "same reply",
timestamp: now + 1,
idempotencyKey: remoteRunId),
errorMessage: nil)))
try await waitUntil("reused provisional reply is visible") {
await MainActor.run {
vm.messages.contains { $0.role == "assistant" && $0.content.first?.text == "same reply" }
}
}
let provisionalID = try await MainActor.run {
try #require(vm.messages.first(where: {
$0.role == "assistant" && $0.content.first?.text == "same reply"
})?.id)
}
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "user",
text: "independent channel request",
timestamp: now + 2),
messageId: "srv-channel-user",
messageSeq: 2)))
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "assistant",
text: "same reply",
timestamp: now + 3),
messageId: "srv-channel-reply",
messageSeq: 3)))
try await waitUntil("independent channel reply remains distinct") {
await MainActor.run {
let replies = vm.messages.filter {
$0.role == "assistant" && $0.content.first?.text == "same reply"
}
guard replies.count == 2, replies.first?.id == provisionalID else { return false }
guard let userIndex = vm.messages.firstIndex(where: {
$0.role == "user" && $0.content.first?.text == "independent channel request"
}),
let canonicalIndex = vm.messages.firstIndex(where: { $0.id == replies[1].id })
else {
return false
}
return userIndex < canonicalIndex
}
}
await finalRefreshGate.release()
}
@Test func `late transformed canonical user keeps reused run final scope`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let responseGate = AsyncGate()
let finalRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let emptyHistory = historyPayload(sessionId: sessionId)
let (transport, vm) = await makeViewModel(
historyResponses: [emptyHistory, emptyHistory],
requestHistoryHook: { _ in
let count = await historyCount.increment()
if count == 3 {
await finalRefreshGate.wait()
}
},
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
await responseGate.open()
try await waitUntil("optimistic user adopts reused run identity") {
await MainActor.run {
vm.messages.contains(where: { message in
message.role == "user" && message.idempotencyKey == "\(remoteRunId):user"
})
}
}
try await waitUntil("post-ack history refresh completes") {
await historyCount.current() >= 2
}
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "user",
text: "canonical redacted request",
timestamp: now + 1,
idempotencyKey: "\(remoteRunId):user"),
messageId: "srv-transformed-user",
messageSeq: 1)))
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "assistant",
text: "active reply",
timestamp: now + 2,
idempotencyKey: remoteRunId),
messageId: "srv-active-reply",
messageSeq: 2)))
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "user",
text: "newer request from another client",
timestamp: now + 3,
idempotencyKey: "other-client-run:user"),
messageId: "srv-newer-user",
messageSeq: 3)))
try await waitUntil("canonical user transformation and newer turn arrive") {
await MainActor.run {
vm.messages.containsUserText("canonical redacted request") &&
vm.messages.containsUserText("newer request from another client")
}
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "active reply",
timestamp: now + 4,
idempotencyKey: remoteRunId),
errorMessage: nil)))
try await waitUntil("late canonical adoption does not duplicate reused final") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages
.count(where: { $0.role == "assistant" && $0.content.first?.text == "active reply" }) == 1
}
}
await finalRefreshGate.release()
}
@Test func `history reconciled early final stays in canonical order after delayed event`() async throws {
let sessionId = "sess-main"
let remoteRunId = "existing-active-run"
let now = Date().timeIntervalSince1970 * 1000
let responseGate = AsyncGate()
let historyGate = AsyncGate()
let emptyHistory = historyPayload(sessionId: sessionId)
let canonicalHistory = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same active request",
timestamp: now,
idempotencyKey: "\(remoteRunId):user"),
chatTextMessage(
role: "assistant",
text: "early final reply",
timestamp: now + 2,
idempotencyKey: remoteRunId),
chatTextMessage(
role: "user",
text: "newer channel request",
timestamp: now + 3),
])
let (transport, vm) = await makeViewModel(
historyResponses: [emptyHistory, canonicalHistory],
historyResponseHook: { _, index, _ in
guard index == 1 else { return nil }
await historyGate.wait()
return canonicalHistory
},
sendMessageHook: { _ in
await responseGate.wait()
return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same active request")
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: remoteRunId,
sessionKey: "main",
state: "final",
message: chatTextMessage(
role: "assistant",
text: "early final reply",
timestamp: now + 1,
idempotencyKey: remoteRunId),
errorMessage: nil)))
try await waitUntil("early final is visible before acknowledgement") {
await MainActor.run {
vm.messages.contains {
$0.role == "assistant" && $0.content.first?.text == "early final reply"
}
}
}
let provisionalID = try await MainActor.run {
try #require(vm.messages.first(where: {
$0.role == "assistant" && $0.content.first?.text == "early final reply"
})?.id)
}
await historyGate.open()
try await waitUntil("history adopts early final before newer user") {
await MainActor.run {
guard let replyIndex = vm.messages.firstIndex(where: { $0.id == provisionalID }),
let newerUserIndex = vm.messages.firstIndex(where: {
$0.role == "user" && $0.content.first?.text == "newer channel request"
})
else {
return false
}
return replyIndex < newerUserIndex && vm.messages[replyIndex].timestamp == now + 2
}
}
await responseGate.open()
try await waitUntil("early final run acknowledgement rekeys optimistic user") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.count(where: {
$0.role == "user" && $0.idempotencyKey == "\(remoteRunId):user"
}) == 1
}
}
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: chatTextModelMessage(
role: "assistant",
text: "early final reply",
timestamp: now + 4,
idempotencyKey: remoteRunId),
messageId: "srv-early-final-reply",
messageSeq: 2)))
try await waitUntil("delayed canonical event preserves history order") {
await MainActor.run {
guard vm.messages.count(where: {
$0.role == "assistant" && $0.idempotencyKey == remoteRunId
}) == 1,
let replyIndex = vm.messages.firstIndex(where: { $0.id == provisionalID }),
let newerUserIndex = vm.messages.firstIndex(where: {
$0.role == "user" && $0.content.first?.text == "newer channel request"
})
else {
return false
}
return replyIndex < newerUserIndex && vm.messages[replyIndex].timestamp == now + 2
}
}
}
@Test func `terminal timeout send ack surfaces error and allows next send`() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId, messages: [])
let sendCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sendMessageHook: { runId in
let count = await sendCount.increment()
return OpenClawChatSendResponse(
runId: runId,
status: count == 1 ? "timeout" : "ok")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "first")
try await waitUntil("timeout ack clears pending run") {
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
}
#expect(await transport.sentRunIds().count == 1)
#expect(await MainActor.run { vm.errorText } == "Chat failed before the run started; try again.")
#expect(await MainActor.run { !vm.messages.containsUserText("first") })
await sendUserMessage(vm, text: "second")
try await waitUntil("second send is accepted after timeout ack") {
await transport.sentRunIds().count == 2
}
}
@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],
sendMessageStatus: "pending")
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],
sendMessageStatus: "pending")
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 refreshGate = AsyncGate()
let historyCallCount = AsyncCounter()
let history1 = historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "assistant",
text: "earlier answer",
timestamp: now + 1000),
])
let (_, vm) = await makeViewModel(
historyResponses: [history1],
requestHistoryHook: { _ in
if await historyCallCount.increment() == 2 {
await refreshGate.wait()
}
},
historyResponseHook: { _, index, sentRunIds in
guard index == 1, let runId = sentRunIds.last else { return nil }
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "assistant",
text: "earlier answer",
timestamp: now + 1000),
chatTextMessage(
role: "user",
text: "hello from mac webchat",
timestamp: now + 5000,
idempotencyKey: "\(runId):user"),
chatTextMessage(
role: "assistant",
text: "final answer",
timestamp: now + 6000),
])
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello from mac webchat")
try await waitUntil("canonical refresh starts") { await historyCallCount.current() == 2 }
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
await refreshGate.open()
try await waitUntil("send acknowledgement 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
}
}
#expect(await MainActor.run { vm.messages.last(where: { $0.role == "user" })?.id } == optimisticID)
}
@Test func `metadata free canonical refresh keeps ambiguous user turns distinct`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let refreshGate = AsyncGate()
let historyCallCount = AsyncCounter()
let (_, vm) = await makeViewModel(
historyResponses: [historyPayload(sessionId: sessionId)],
requestHistoryHook: { _ in
if await historyCallCount.increment() == 2 {
await refreshGate.wait()
}
},
historyResponseHook: { _, index, _ in
guard index == 1 else { return nil }
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "legacy echo",
timestamp: now + 5000),
chatTextMessage(
role: "assistant",
text: "legacy answer",
timestamp: now + 6000),
])
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "legacy echo")
try await waitUntil("legacy refresh starts") { await historyCallCount.current() == 2 }
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
await refreshGate.open()
try await waitUntil("metadata free canonical refresh preserves both user rows") {
await MainActor.run {
vm.messages.count(where: { message in
message.role == "user" &&
message.content.compactMap(\.text).joined(separator: "\n") == "legacy echo"
}) == 2 && vm.messages.contains(where: { message in
message.role == "assistant" &&
message.content.compactMap(\.text).joined(separator: "\n") == "legacy answer"
})
}
}
#expect(await MainActor.run { vm.messages.contains(where: { $0.id == optimisticID }) })
}
@Test func `preserves local echo when another client sends identical text during refresh`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(sessionId: sessionId)],
historyResponseHook: { _, index, _ in
guard index == 1 else { return nil }
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "same words",
timestamp: now + 5000,
idempotencyKey: "other-client:user"),
chatTextMessage(
role: "assistant",
text: "other client's answer",
timestamp: now + 6000),
])
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "same words")
try await waitUntil("foreign identical turn and local echo both survive") {
await MainActor.run {
vm.pendingRunCount == 0 &&
vm.messages.count(where: { message in
message.role == "user" &&
message.content.compactMap(\.text).joined(separator: "\n") == "same words"
}) == 2
}
}
#expect(await transport.sentRunIds().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 (transport, vm) = await makeViewModel(
historyResponses: [history1],
historyResponseHook: { _, index, sentRunIds in
guard index > 0, let firstRunId = sentRunIds.first else { return nil }
return historyPayload(
sessionId: sessionId,
messages: [
chatTextMessage(
role: "user",
text: "retry",
timestamp: now + 5000,
idempotencyKey: "\(firstRunId):user"),
chatTextMessage(
role: "assistant",
text: "first answer",
timestamp: now + 6000),
])
})
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 @MainActor func `transformed canonical reply invalidates older stale refresh`() async throws {
let staleRefreshGate = SessionSubscribeGate()
let historyCount = AsyncCounter()
let now = Date().timeIntervalSince1970 * 1000
let staleTurn = [
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(sessionId: "sess-bootstrap", messages: staleTurn),
historyPayload(sessionId: "sess-stale", messages: staleTurn),
],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await historyCount.increment()
if count == 2 {
await staleRefreshGate.wait()
}
},
historyResponseHook: { _, index, sentRunIds in
guard index == 2, let runId = sentRunIds.first else { return nil }
return historyPayload(
sessionId: "sess-canonical",
messages: [
chatTextMessage(
role: "user",
text: "canonical redacted request",
timestamp: now,
idempotencyKey: "\(runId):user"),
chatTextMessage(
role: "assistant",
text: "canonical answer",
timestamp: now + 1,
idempotencyKey: runId),
])
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-bootstrap")
transport.emit(OpenClawChatTransportEvent.seqGap)
try await waitUntil("stale transformed refresh is in flight") {
await historyCount.current() == 2
}
vm.input = "original request"
vm.send()
_ = try await waitForLastSentRunId(transport)
try await waitUntil("transformed canonical answer applies") {
await MainActor.run {
vm.sessionId == "sess-canonical" &&
vm.messages.containsUserText("canonical redacted request") &&
vm.messages.contains { $0.content.contains { $0.text == "canonical answer" } }
}
}
let healthCallsBeforeRelease = await transport.healthCallCount()
await staleRefreshGate.release()
try await waitUntil("older transformed refresh completes") {
await transport.healthCallCount() > healthCallsBeforeRelease
}
#expect(vm.sessionId == "sess-canonical")
#expect(vm.messages.containsUserText("canonical redacted request"))
#expect(vm.messages.contains { $0.content.contains { $0.text == "canonical 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],
sendMessageStatus: "pending")
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],
sendMessageStatus: "pending")
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 `exact ordinary session matches before agent bootstrap`() async {
let matches = await MainActor.run {
(
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "main",
agentId: "work",
current: "main",
mainSessionKey: "main"),
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "main",
agentId: "work",
current: "main",
mainSessionKey: "main",
activeAgentId: "main"))
}
#expect(matches.0)
#expect(!matches.1)
}
@Test func `agent scoped opaque event matches only its presentation owner`() async {
let matches = await MainActor.run {
(
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
current: "Matrix:Channel:!MixedRoom:example.org",
mainSessionKey: "main",
activeAgentId: "reviewer"),
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
agentId: "work",
current: "Matrix:Channel:!MixedRoom:example.org",
mainSessionKey: "main",
activeAgentId: "reviewer"),
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
current: "Matrix:Channel:!MixedRoom:example.org",
mainSessionKey: "main",
activeAgentId: "work"))
}
#expect(matches.0)
#expect(!matches.1)
#expect(!matches.2)
}
@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()],
sendMessageStatus: "pending")
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()],
sendMessageHook: { runId in
OpenClawChatSendResponse(runId: runId, status: "pending")
})
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
await sendUserMessage(vm, text: "echo me")
let runId = try await waitForLastSentRunId(transport)
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,
idempotencyKey: "\(runId):user"),
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 `late correlated user echo replaces optimistic row after final`() async throws {
let history = historyPayload()
let (transport, vm) = await makeViewModel(
historyResponses: [history, history],
sendMessageStatus: "pending")
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
await sendUserMessage(vm, text: "sensitive draft")
let runId = try await waitForLastSentRunId(transport)
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("final clears pending correlation bookkeeping") {
await MainActor.run { vm.pendingRunCount == 0 }
}
let canonicalTimestamp = Date().timeIntervalSince1970 * 1000 + 5000
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "redacted canonical text",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: canonicalTimestamp,
idempotencyKey: "\(runId):user"),
messageId: "srv-late-user-echo",
messageSeq: 2)))
try await waitUntil("late canonical echo replaces optimistic row") {
await MainActor.run {
vm.messages.count(where: { $0.role == "user" }) == 1 &&
vm.messages.contains(where: { message in
message.id == optimisticID &&
message.content.first?.text == "redacted canonical text" &&
message.timestamp == canonicalTimestamp
})
}
}
}
@Test func `metadata free same text event cannot consume pending local identity`() async throws {
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sendMessageStatus: "pending")
await MainActor.run { vm.load() }
try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } }
await sendUserMessage(vm, text: "legacy echo")
let runId = try await waitForLastSentRunId(transport)
let optimisticID = try await MainActor.run {
try #require(vm.messages.last(where: { $0.role == "user" })?.id)
}
let canonicalTimestamp = Date().timeIntervalSince1970 * 1000 + 5000
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "legacy echo",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: canonicalTimestamp),
messageId: "srv-legacy-echo-1",
messageSeq: 1)))
try await waitUntil("ambiguous metadata free event remains distinct") {
await MainActor.run {
vm.messages.count(where: { message in
message.role == "user" && message.content.first?.text == "legacy echo"
}) == 2
}
}
let localCanonicalTimestamp = canonicalTimestamp + 1000
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "agent:main:main",
message: OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "text",
text: "legacy echo",
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: localCanonicalTimestamp,
idempotencyKey: "\(runId):user"),
messageId: "srv-local-echo-1",
messageSeq: 2)))
try await waitUntil("later correlated local echo adopts only the optimistic row") {
await MainActor.run {
vm.messages.count(where: { message in
message.role == "user" && message.content.first?.text == "legacy echo"
}) == 2 && vm.messages.contains(where: { message in
message.id == optimisticID &&
message.timestamp == localCanonicalTimestamp &&
message.idempotencyKey == "\(runId):user"
}) && vm.messages.contains(where: { message in
message.timestamp == canonicalTimestamp && message.idempotencyKey == nil
})
}
}
}
@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],
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm)
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: "stale partial")
emitToolStart(transport: transport, runId: runId)
try await waitUntil("pre-gap transient state") {
await MainActor.run {
vm.streamingAssistantText == "stale partial" && vm.pendingToolCalls.count == 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.streamingAssistantText } == nil)
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
#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 `context usage follows active session switches`() async throws {
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(
key: "main",
updatedAt: 2,
totalTokens: 20,
totalTokensFresh: true,
contextTokens: 100),
sessionEntry(
key: "other",
updatedAt: 1,
totalTokens: 80,
totalTokensFresh: true,
contextTokens: 100),
])
let (_, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
sessionsResponses: [sessions, sessions])
await MainActor.run { vm.load() }
try await waitUntil("main context usage loaded") {
await MainActor.run { vm.contextUsageFraction == 0.2 }
}
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("other context usage selected") {
await MainActor.run { vm.contextUsageFraction == 0.8 }
}
}
@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 `new trigger keeps selected global agent scope`() async throws {
let (transport, vm) = await makeViewModel(
sessionKey: "global",
activeAgentId: "reviewer",
historyResponses: [historyPayload(sessionKey: "global"), historyPayload()])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/new"
vm.send()
}
try await waitUntil("fresh selected-agent session created") {
await MainActor.run { vm.sessionKey.hasPrefix("agent:reviewer:ios-") }
}
#expect(await transport.createdSessionKeys().first?.hasPrefix("agent:reviewer:ios-") == true)
#expect(await transport.createdParentSessionKeys() == ["global"])
}
@Test func `new trigger prefers explicit session agent over ambient agent`() async throws {
let (transport, vm) = await makeViewModel(
sessionKey: "agent:alice:main",
activeAgentId: "main",
historyResponses: [historyPayload(sessionKey: "agent:alice:main"), historyPayload()])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/new"
vm.send()
}
try await waitUntil("fresh explicit-agent session created") {
await MainActor.run { vm.sessionKey.hasPrefix("agent:alice:ios-") }
}
#expect(await transport.createdSessionKeys().first?.hasPrefix("agent:alice:ios-") == true)
#expect(await transport.createdParentSessionKeys() == ["agent:alice:main"])
}
@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" && !vm.isLoading
}
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact cooldown rejects immediate retry") {
await MainActor.run {
vm.errorText == "Please wait before compacting this session again."
}
}
#expect(await transport.compactSessionKeys() == ["main"])
}
@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 `slash command catalog filters commands and skills`() async throws {
let commands = [
commandChoice(
name: "compact",
aliases: ["/compact"],
description: "Compact the session",
source: .command),
commandChoice(
name: "review",
aliases: ["/review"],
description: "Review the current change",
source: .skill,
acceptsArgs: true),
]
let (_, vm) = await makeViewModel(
historyResponses: [historyPayload()],
commandResponses: [commands])
await MainActor.run { vm.loadSlashCommandsIfNeeded() }
try await waitUntil("slash commands loaded") {
await MainActor.run { vm.hasLoadedSlashCommands }
}
let allMatches = await MainActor.run {
vm.slashCommandMatches(query: "/", filter: .all).map(\.name)
}
#expect(allMatches == ["compact", "review"])
let commandMatches = await MainActor.run {
vm.slashCommandMatches(query: "/co", filter: .commands).map(\.name)
}
#expect(commandMatches == ["compact"])
let skillMatches = await MainActor.run {
vm.slashCommandMatches(query: "/skill re", filter: .all).map(\.name)
}
#expect(skillMatches == ["review"])
await MainActor.run {
vm.applySlashCommandSelection(commands[1])
}
#expect(await MainActor.run { vm.input } == "/review ")
}
@Test func `known slash command sends through chat send`() async throws {
let commands = [
commandChoice(
name: "model",
aliases: ["/model"],
description: "Change model",
source: .command,
acceptsArgs: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(), historyPayload()],
commandResponses: [commands])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "/model gpt-5")
_ = try await waitForLastSentRunId(transport)
#expect(await transport.sentMessages() == ["/model gpt-5"])
}
@Test func `slash command catalog loads for current session`() async throws {
let commands = [
commandChoice(name: "model", aliases: ["/model"], source: .command, acceptsArgs: true),
]
let (transport, vm) = await makeViewModel(
sessionKey: "agent:reviewer:main",
historyResponses: [historyPayload()],
commandResponses: [commands])
await MainActor.run { vm.loadSlashCommandsIfNeeded() }
try await waitUntil("slash commands loaded") {
await MainActor.run { vm.hasLoadedSlashCommands }
}
#expect(await transport.commandSessionKeys() == ["agent:reviewer:main"])
}
@Test func `unknown leading slash is sent to gateway after command catalog loads`() async throws {
let commands = [
commandChoice(name: "model", aliases: ["/model"], source: .command, acceptsArgs: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(), historyPayload()],
commandResponses: [commands])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "/does-not-exist")
_ = try await waitForLastSentRunId(transport)
#expect(await transport.sentMessages() == ["/does-not-exist"])
}
@Test func `double slash sends as ordinary text`() async throws {
let commands = [
commandChoice(name: "model", aliases: ["/model"], source: .command, acceptsArgs: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(), historyPayload()],
commandResponses: [commands])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "//does-not-trigger")
_ = try await waitForLastSentRunId(transport)
#expect(await transport.sentMessages() == ["//does-not-trigger"])
}
@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)
try await waitUntil("model metadata bootstrap") {
await MainActor.run {
vm.showsModelPicker
&& vm.modelSelectionID == "anthropic/claude-opus-4-6"
&& vm.defaultModelLabel == "Default: openai/gpt-4.1-mini"
}
}
#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 @MainActor func `successful model selection records recent and selected pin updates sections`() async throws {
let suiteName = "ChatViewModelTests.modelPicker.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let modelPickerStore = ChatModelPickerStore(defaults: defaults)
let now = Date().timeIntervalSince1970 * 1000
let selectedID = "openai/gpt-5.4"
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "claude-opus-4-6", name: "Claude Opus 4.6"),
]
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [sessionEntry(key: "main", updatedAt: now, model: nil)])
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sessionsResponses: [sessions],
modelResponses: [models],
modelPickerStore: modelPickerStore)
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel(selectedID) }
try await waitUntil("successful model selection recorded as recent") {
await MainActor.run { vm.modelPickerSections.recent.map(\.selectionID) == [selectedID] }
}
#expect(await transport.patchedModels() == [selectedID])
#expect(modelPickerStore.recents == [selectedID])
await MainActor.run { vm.toggleSelectedModelPinned() }
#expect(await MainActor.run { vm.isSelectedModelPinned })
#expect(await MainActor.run { vm.modelPickerSections.pinned.map(\.selectionID) } == [selectedID])
#expect(await MainActor.run { vm.modelPickerSections.recent.isEmpty })
}
@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 @MainActor func `stale model patch completions do not overwrite newer selection`() async throws {
let suiteName = "ChatViewModelTests.staleModelPicker.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let modelPickerStore = ChatModelPickerStore(defaults: defaults)
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))
}
},
modelPickerStore: modelPickerStore)
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
try await waitUntil("older model patch starts") {
await transport.patchedModels() == ["openai/gpt-5.4"]
}
await MainActor.run { vm.selectModel("openai/gpt-5.4-pro") }
try await waitUntil("two model patches issued") {
await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
await sendUserMessage(vm, text: "after model patches")
_ = try await waitForLastSentRunId(transport)
#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")
#expect(modelPickerStore.recents == ["openai/gpt-5.4-pro"])
}
@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") }
try await waitUntil("older model patch starts") {
await transport.patchedModels() == ["openai/gpt-5.4"]
}
await MainActor.run { 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") }
try await waitUntil("earlier model patch starts") {
await transport.patchedModels() == ["openai/gpt-5.4"]
}
await MainActor.run { 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 preserves final event and 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")],
requestHistoryHook: { sessionKey in
guard sessionKey == "main" else { return }
let count = await mainHistoryCount.increment()
if count == 3 {
await staleFallbackGate.wait()
_ = await staleFallbackReleasedCount.increment()
}
},
historyResponseHook: { _, index, sentRunIds in
guard let runId = sentRunIds.last else { return nil }
if (1...3).contains(index) {
let sessionId = switch index {
case 1: "sess-main-send-refresh"
case 2: "sess-main-stale-fallback"
default: "sess-main-newer-empty-refresh"
}
return historyPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)],
inFlightRun: OpenClawChatInFlightRun(runId: runId, text: ""))
}
guard index == 4 else { return nil }
return 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),
])
},
sendMessageStatus: "pending")
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 `session activity without chat snapshot does not retain completed pending run`() async throws {
let historyCalls = AsyncCounter()
let now = (Date().timeIntervalSince1970 * 1000) + 10000
let completedHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "done", timestamp: now + 1),
],
hasActiveRun: true)
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(), completedHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm)
vm.input = "hello"
vm.send()
_ = try await waitForLastSentRunId(transport)
try await waitUntil("completed chat run clears despite unrelated session activity") {
let historyCount = await historyCalls.current()
let pendingRunCount = await MainActor.run { vm.pendingRunCount }
return historyCount >= 2 && pendingRunCount == 0
}
#expect(vm.messages.contains { message in
message.content.contains { $0.text == "done" }
})
}
@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"),
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") }
try await waitUntil("main session model patch starts") {
await transport.patchedModels() == ["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" }
}
await MainActor.run { vm.switchSession(to: "main") }
try await waitUntil("returned to original session") {
await MainActor.run { vm.sessionKey == "main" && vm.sessionId == "sess-main" }
}
await sendUserMessage(vm, text: "after late model patch")
_ = try await waitForLastSentRunId(transport)
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("reopened other session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
#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") }
try await waitUntil("main session model patch starts") {
await transport.patchedModels() == ["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",
"totalTokens": 25000,
"totalTokensFresh": false,
"contextTokens": 100000,
"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")
#expect(decoded.sessions.first?.totalTokensFresh == false)
}
@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 picker follows gateway metadata before current level augmentation`() async throws {
let history = historyPayload(sessionId: "sess-main")
let offOnlySessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "reasoning-model",
modelProvider: "openai",
thinkingLevel: "medium",
thinkingLevels: [thinkingOption("off")]))
let reasoningValues: [Bool?] = [true, nil]
for reasoning in reasoningValues {
let models = [
modelChoice(
id: "reasoning-model",
name: "Reasoning Model",
provider: "openai",
reasoning: reasoning),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [offOnlySessions],
modelResponses: [models],
initialThinkingLevel: "medium")
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
try await waitUntil("off-only thinking metadata applied") {
await MainActor.run { vm.thinkingLevelOptions.map(\.id) == ["off", "medium"] }
}
#expect(await MainActor.run { !vm.showsThinkingPicker })
}
let multiLevelSessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: nil,
thinkingLevels: [thinkingOption("off"), thinkingOption("high")]))
let (_, multiLevelVM) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [multiLevelSessions])
try await loadAndWaitBootstrap(vm: multiLevelVM, sessionId: "sess-main")
try await waitUntil("multi-level thinking metadata applied") {
await MainActor.run { multiLevelVM.thinkingLevelOptions.map(\.id) == ["off", "high"] }
}
#expect(await MainActor.run { multiLevelVM.showsThinkingPicker })
let (_, legacyVM) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: legacyVM, sessionId: "sess-main")
#expect(await MainActor.run { legacyVM.showsThinkingPicker })
#expect(await MainActor.run { legacyVM.thinkingLevelOptions.map(\.id) } ==
["off", "minimal", "low", "medium", "high"])
}
@Test func `gated thinking picker sends off without changing stored level`() async throws {
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "plain-model",
modelProvider: "openai",
thinkingLevels: [thinkingOption("off"), thinkingOption("medium")]))
let models = [
modelChoice(id: "plain-model", name: "Plain Model", provider: "openai", reasoning: false),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
initialThinkingLevel: "medium")
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { !vm.showsThinkingPicker })
await sendUserMessage(vm, text: "hello")
try await waitUntil("gated send uses off") {
await transport.sentThinkingLevels() == ["off"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
}
@Test func `ungated thinking picker sends stored level`() async throws {
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "reasoning-model",
modelProvider: "openai",
thinkingLevels: [thinkingOption("off"), thinkingOption("medium")]))
let models = [
modelChoice(
id: "reasoning-model",
name: "Reasoning Model",
provider: "openai",
reasoning: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
initialThinkingLevel: "medium")
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.showsThinkingPicker })
await sendUserMessage(vm, text: "hello")
try await waitUntil("ungated send uses stored level") {
await transport.sentThinkingLevels() == ["medium"]
}
}
@Test func `switching back to reasoning model restores stored thinking level for send`() async throws {
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "reasoning-model",
modelProvider: "openai",
thinkingLevels: [thinkingOption("off"), thinkingOption("medium")]))
let models = [
modelChoice(
id: "reasoning-model",
name: "Reasoning Model",
provider: "openai",
reasoning: true),
modelChoice(id: "plain-model", name: "Plain Model", provider: "openai", reasoning: false),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
initialThinkingLevel: "medium")
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/plain-model") }
try await waitUntil("plain model selected") {
await MainActor.run {
vm.sessions.first?.model == "plain-model" && !vm.showsThinkingPicker
}
}
await sendUserMessage(vm, text: "plain send")
try await waitUntil("plain send uses off") {
await transport.sentThinkingLevels() == ["off"]
}
try await waitUntil("plain send completed") {
await MainActor.run { !vm.isSending && vm.pendingRunCount == 0 }
}
await MainActor.run { vm.selectModel("openai/reasoning-model") }
try await waitUntil("reasoning model restored") {
await MainActor.run {
vm.sessions.first?.model == "reasoning-model" && vm.showsThinkingPicker
}
}
await sendUserMessage(vm, text: "reasoning send")
try await waitUntil("reasoning send restores stored level") {
await transport.sentThinkingLevels() == ["off", "medium"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
}
@Test func `send reapplies thinking gate after model patch rollback`() async throws {
let modelPatchGate = SessionSubscribeGate()
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "plain-model",
modelProvider: "openai",
thinkingLevels: [thinkingOption("off"), thinkingOption("medium")]))
let models = [
modelChoice(id: "plain-model", name: "Plain Model", provider: "openai", reasoning: false),
modelChoice(
id: "reasoning-model",
name: "Reasoning Model",
provider: "openai",
reasoning: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/reasoning-model" {
await modelPatchGate.wait()
throw NSError(domain: "test", code: 1)
}
},
initialThinkingLevel: "medium")
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/reasoning-model") }
try await waitUntil("reasoning model patch started") {
let pickerShown = await MainActor.run { vm.showsThinkingPicker }
let patchedModels = await transport.patchedModels()
return pickerShown && patchedModels == ["openai/reasoning-model"]
}
await sendUserMessage(vm, text: "send after rollback")
try await waitUntil("send waits for model patch") {
let isSending = await MainActor.run { vm.isSending }
let sentThinkingLevels = await transport.sentThinkingLevels()
return isSending && sentThinkingLevels.isEmpty
}
await modelPatchGate.release()
try await waitUntil("rolled back send uses off") {
let rolledBack = await MainActor.run {
vm.modelSelectionID == "openai/plain-model" && !vm.showsThinkingPicker
}
let sentThinkingLevels = await transport.sentThinkingLevels()
return rolledBack && sentThinkingLevels == ["off"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
}
@Test func `non-reasoning model selection hides picker before session refresh`() async throws {
let modelPatchGate = SessionSubscribeGate()
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "reasoning-model",
modelProvider: "openai",
thinkingLevels: [thinkingOption("off"), thinkingOption("high")]))
let models = [
modelChoice(
id: "reasoning-model",
name: "Reasoning Model",
provider: "openai",
reasoning: true),
modelChoice(
id: "plain-model",
name: "Plain Model",
provider: "openai",
reasoning: false),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/plain-model" {
await modelPatchGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
try await waitUntil("reasoning model loaded") {
await MainActor.run {
vm.modelSelectionID == "openai/reasoning-model" && vm.showsThinkingPicker
}
}
await MainActor.run { vm.selectModel("openai/plain-model") }
try await waitUntil("local non-reasoning selection gated") {
await MainActor.run {
vm.modelSelectionID == "openai/plain-model" &&
!vm.showsThinkingPicker &&
vm.sessions.first?.model == "reasoning-model"
}
}
await modelPatchGate.release()
}
@Test func `reselecting the same model preserves thinking metadata`() async throws {
let modelPatchGate = SessionSubscribeGate()
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: " model-x ",
modelProvider: " openai ",
thinkingLevels: [thinkingOption("off")],
thinkingOptions: ["off"],
thinkingDefault: "off"))
let models = [
modelChoice(id: "model-x", name: "Model X", provider: "openai", reasoning: true),
modelChoice(id: "model-y", name: "Model Y", provider: "openai", reasoning: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/model-y" {
await modelPatchGate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { !vm.showsThinkingPicker })
await MainActor.run { vm.selectModel("openai/model-y") }
try await waitUntil("model Y patch started") {
await transport.patchedModels() == ["openai/model-y"]
}
await MainActor.run { vm.selectModel("openai/model-x") }
try await waitUntil("model X re-selection patched") {
await transport.patchedModels() == ["openai/model-y", "openai/model-x"]
}
await modelPatchGate.release()
await vm.waitForPendingModelPatches(in: "main")
#expect(await MainActor.run { vm.sessions.first?.thinkingLevels?.map(\.id) } == ["off"])
#expect(await MainActor.run { vm.sessions.first?.thinkingOptions } == ["off"])
#expect(await MainActor.run { vm.sessions.first?.thinkingDefault } == "off")
#expect(await MainActor.run { !vm.showsThinkingPicker })
}
@Test func `switching models drops stale thinking metadata`() async throws {
let history = historyPayload(sessionId: "sess-main")
let sessions = sessionsResponse(
sessionEntry(
key: "main",
updatedAt: 1,
model: "model-x",
modelProvider: "openai",
thinkingLevels: [thinkingOption("off")],
thinkingOptions: ["off"],
thinkingDefault: "off"))
let models = [
modelChoice(id: "model-x", name: "Model X", provider: "openai", reasoning: true),
modelChoice(id: "model-y", name: "Model Y", provider: "openai", reasoning: true),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { !vm.showsThinkingPicker })
await MainActor.run { vm.selectModel("openai/model-y") }
try await waitUntil("model Y patch completed") {
await MainActor.run {
vm.sessions.first?.model == "model-y" && vm.showsThinkingPicker
}
}
#expect(await transport.patchedModels() == ["openai/model-y"])
#expect(await MainActor.run { vm.sessions.first?.thinkingLevels == nil })
#expect(await MainActor.run { vm.sessions.first?.thinkingOptions == nil })
#expect(await MainActor.run { vm.sessions.first?.thinkingDefault == nil })
}
@Test func `default model selection resolves session model reasoning`() async throws {
let history = historyPayload(sessionId: "sess-main")
let models = [
modelChoice(id: "plain-model", name: "Plain Model", provider: "openai", reasoning: false),
modelChoice(id: "reasoning-model", name: "Reasoning Model", provider: "openai", reasoning: true),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
try await waitUntil("models loaded with default selection") {
await MainActor.run {
vm.modelChoices.count == 2 &&
vm.modelSelectionID == OpenClawChatViewModel.defaultModelSelectionID
}
}
await MainActor.run {
vm.sessions = [
sessionEntry(
key: "main",
updatedAt: 1,
model: "plain-model",
modelProvider: "openai"),
]
vm.syncThinkingLevelOptions()
}
#expect(await MainActor.run { !vm.showsThinkingPicker })
await MainActor.run {
vm.sessions = [
sessionEntry(
key: "main",
updatedAt: 1,
model: "reasoning-model",
modelProvider: "openai"),
]
vm.syncThinkingLevelOptions()
}
#expect(await MainActor.run { vm.showsThinkingPicker })
}
@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") }
try await waitUntil("older thinking patch starts") {
await transport.patchedThinkingLevels() == ["medium"]
}
await MainActor.run { 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],
sendMessageStatus: "pending")
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 } }
}
}
@Suite(.serialized)
struct ChatViewModelSessionManagementTests {
@Test @MainActor func `session list organizer orders pinned first with key tiebreak`() {
let organized = OpenClawChatSessionListOrganizer.organize([
sessionEntry(key: "c-tie", updatedAt: 100),
sessionEntry(key: "a-tie", updatedAt: 100),
sessionEntry(key: "recent", updatedAt: 500),
sessionEntry(key: "pinned-old", updatedAt: 10, pinned: true, pinnedAt: 1),
sessionEntry(key: "pinned-new", updatedAt: 5, pinned: true, pinnedAt: 2),
])
#expect(organized.map(\.key) == ["pinned-new", "pinned-old", "recent", "a-tie", "c-tie"])
}
@Test @MainActor func `session list organizer filters across display fields`() {
let sessions = [
sessionEntry(key: "agent:main:topic-a", updatedAt: 2, displayName: "Trip planning"),
sessionEntry(key: "agent:main:topic-b", updatedAt: 1, displayName: "Groceries"),
sessionEntry(key: "agent:main:trip-notes", updatedAt: 3, displayName: "Notes"),
]
let matched = OpenClawChatSessionListOrganizer.filter(sessions, search: "TRIP")
#expect(matched.map(\.key) == ["agent:main:topic-a", "agent:main:trip-notes"])
#expect(OpenClawChatSessionListOrganizer.filter(sessions, search: " ") == sessions)
}
@Test func `pin patches transport and reorders optimistically`() async throws {
let initial = sessionsListResponse([
sessionEntry(key: "agent:main:topic-a", updatedAt: 200),
sessionEntry(key: "agent:main:topic-b", updatedAt: 100),
])
let pinned = sessionsListResponse([
sessionEntry(key: "agent:main:topic-b", updatedAt: 100, pinned: true, pinnedAt: 300),
sessionEntry(key: "agent:main:topic-a", updatedAt: 200),
])
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sessionsResponses: [initial, pinned])
await MainActor.run { vm.refreshSessions() }
try await waitUntil("initial sessions applied") {
await MainActor.run { vm.sessions.map(\.key) == ["agent:main:topic-a", "agent:main:topic-b"] }
}
await MainActor.run { vm.setSessionPinned(key: "agent:main:topic-b", pinned: true) }
// Optimistic reorder happens before the transport call settles.
#expect(await MainActor.run { vm.sessions.first?.key } == "agent:main:topic-b")
try await waitUntil("pin patch sent") {
let changes = await transport.pinnedChanges()
return changes.count == 1 && changes[0].key == "agent:main:topic-b" && changes[0].pinned
}
try await waitUntil("refresh keeps pinned order") {
await MainActor.run { vm.sessions.first?.isPinned == true }
}
}
@Test func `rename patches label optimistically and reverts on failure`() async throws {
let initial = sessionsListResponse([
sessionEntry(key: "agent:main:topic-a", updatedAt: 200, displayName: "Old name"),
])
// The post-rename refresh must return the renamed row; otherwise the
// refetch legitimately repaints the old name and races the assertions.
let renamed = sessionsListResponse([
sessionEntry(
key: "agent:main:topic-a",
updatedAt: 200,
displayName: "Trip planning",
label: "Trip planning"),
])
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sessionsResponses: [initial, renamed],
renameSessionHook: { _, label in
if label == "Bad name" {
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "rename failed"])
}
})
await MainActor.run { vm.refreshSessions() }
try await waitUntil("initial sessions applied") {
await MainActor.run { !vm.sessions.isEmpty }
}
await MainActor.run { vm.renameSession(key: "agent:main:topic-a", label: " Trip planning ") }
#expect(await MainActor.run { vm.sessions.first?.displayName } == "Trip planning")
try await waitUntil("rename sent trimmed label") {
let renames = await transport.renamedLabels()
return renames.count == 1 && renames[0].label == "Trip planning"
}
// Let the post-rename refresh settle so the failing rename below
// captures a deterministic pre-mutation snapshot to revert to.
try await waitUntil("post-rename refresh applied") {
await transport.listSessionsQueries().count >= 2
}
await MainActor.run { vm.renameSession(key: "agent:main:topic-a", label: "Bad name") }
try await waitUntil("failed rename reverts") {
await MainActor.run {
vm.sessions.first?.displayName == "Trip planning" && vm.errorText == "rename failed"
}
}
}
@Test func `archive removes the session from the active list`() async throws {
let initial = sessionsListResponse([
sessionEntry(key: "agent:main:topic-a", updatedAt: 200),
sessionEntry(key: "agent:main:topic-b", updatedAt: 100),
])
let afterArchive = sessionsListResponse([
sessionEntry(key: "agent:main:topic-a", updatedAt: 200),
])
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sessionsResponses: [initial, afterArchive])
await MainActor.run { vm.refreshSessions() }
try await waitUntil("initial sessions applied") {
await MainActor.run { vm.sessions.count == 2 }
}
await MainActor.run { vm.setSessionArchived(key: "agent:main:topic-b", archived: true) }
#expect(await MainActor.run { vm.sessions.map(\.key) } == ["agent:main:topic-a"])
try await waitUntil("archive patch sent") {
let changes = await transport.archivedChanges()
return changes.count == 1 && changes[0].key == "agent:main:topic-b" && changes[0].archived
}
}
@Test func `fetchSessionList sends search and archived to the server`() async throws {
let archivedEntry = sessionEntry(key: "agent:main:old", updatedAt: 10, archived: true)
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
listSessionsHook: { query in
query.archived == true ? sessionsListResponse([archivedEntry]) : nil
})
let archivedRows = await vm.fetchSessionList(search: nil, archived: true)
#expect(archivedRows.map(\.key) == ["agent:main:old"])
_ = await vm.fetchSessionList(search: " trip ", archived: false)
let queries = await transport.listSessionsQueries()
#expect(queries.contains(TestSessionListQuery(limit: 200, search: nil, archived: true)))
#expect(queries.contains(TestSessionListQuery(limit: 200, search: "trip", archived: false)))
}
@Test func `restore session only reports success when the patch lands`() async throws {
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload()],
setSessionArchivedHook: { key, archived in
if !archived, key == "agent:main:broken" {
throw NSError(domain: "test", code: 9, userInfo: [NSLocalizedDescriptionKey: "restore failed"])
}
})
let restored = await vm.restoreSession(key: "agent:main:old")
#expect(restored)
let failed = await vm.restoreSession(key: "agent:main:broken")
#expect(!failed)
#expect(await MainActor.run { vm.errorText } == "restore failed")
let changes = await transport.archivedChanges()
#expect(changes.map(\.key) == ["agent:main:old", "agent:main:broken"])
#expect(changes.allSatisfy { !$0.archived })
}
@Test func `fetchSessionList falls back to local filtering when the server is unreachable`() async throws {
let cached = sessionsListResponse([
sessionEntry(key: "agent:main:topic-a", updatedAt: 2, displayName: "Trip planning"),
sessionEntry(key: "agent:main:topic-b", updatedAt: 1, displayName: "Groceries"),
])
let (_, vm) = await makeViewModel(
historyResponses: [historyPayload()],
sessionsResponses: [cached],
listSessionsHook: { query in
if query.search != nil || query.archived == true {
throw NSError(domain: "test", code: 7, userInfo: [NSLocalizedDescriptionKey: "offline"])
}
return nil
})
await MainActor.run { vm.refreshSessions() }
try await waitUntil("cached sessions applied") {
await MainActor.run { vm.sessions.count == 2 }
}
let filtered = await vm.fetchSessionList(search: "trip", archived: false)
#expect(filtered.map(\.key) == ["agent:main:topic-a"])
// Archived rows only exist server-side; offline archived mode is empty.
let archivedRows = await vm.fetchSessionList(search: nil, archived: true)
#expect(archivedRows.isEmpty)
}
}