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