Download apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift from Darochin/openskynet: direct link, hf CLI and curl.
- Browser
- Download file 51.6 kB
-
https://huggingface.co/Darochin/openskynet/resolve/main/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
- Command line
-
hf download hf://Darochin/openskynet/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
-
curl -L -o ChatViewModelTests.swift https://huggingface.co/Darochin/openskynet/resolve/main/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
51.6 kB
| import OpenClawKit | |
| import Foundation | |
| 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 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 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]] = [], | |
| resetSessionHook: ( (String) async throws -> Void)? = nil, | |
| setSessionModelHook: ( (String?) async throws -> Void)? = nil, | |
| setSessionThinkingHook: ( (String) async throws -> Void)? = nil, | |
| initialThinkingLevel: String? = nil, | |
| onThinkingLevelChanged: ( (String) -> Void)? = nil) async | |
| -> (TestChatTransport, OpenClawChatViewModel) | |
| { | |
| let transport = TestChatTransport( | |
| historyResponses: historyResponses, | |
| sessionsResponses: sessionsResponses, | |
| modelResponses: modelResponses, | |
| resetSessionHook: resetSessionHook, | |
| setSessionModelHook: setSessionModelHook, | |
| setSessionThinkingHook: setSessionThinkingHook) | |
| let vm = await MainActor.run { | |
| OpenClawChatViewModel( | |
| sessionKey: sessionKey, | |
| transport: transport, | |
| initialThinkingLevel: initialThinkingLevel, | |
| 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 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 emitExternalFinal( | |
| transport: TestChatTransport, | |
| runId: String = "other-run", | |
| sessionKey: String = "main") | |
| { | |
| transport.emit( | |
| .chat( | |
| OpenClawChatEventPayload( | |
| runId: runId, | |
| sessionKey: sessionKey, | |
| state: "final", | |
| message: nil, | |
| errorMessage: nil))) | |
| } | |
| private final class CallbackBox { | |
| var values: [String] = [] | |
| } | |
| private actor AsyncGate { | |
| private var continuation: CheckedContinuation<Void, Never>? | |
| func wait() async { | |
| await withCheckedContinuation { continuation in | |
| self.continuation = continuation | |
| } | |
| } | |
| func open() { | |
| self.continuation?.resume() | |
| self.continuation = nil | |
| } | |
| } | |
| private actor TestChatTransportState { | |
| var historyCallCount: Int = 0 | |
| var sessionsCallCount: Int = 0 | |
| var modelsCallCount: Int = 0 | |
| var resetSessionKeys: [String] = [] | |
| var sentRunIds: [String] = [] | |
| var sentThinkingLevels: [String] = [] | |
| var abortedRunIds: [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 resetSessionHook: ( (String) async throws -> Void)? | |
| private let setSessionModelHook: ( (String?) async throws -> Void)? | |
| private let setSessionThinkingHook: ( (String) async throws -> Void)? | |
| private let stream: AsyncStream<OpenClawChatTransportEvent> | |
| private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation | |
| init( | |
| historyResponses: [OpenClawChatHistoryPayload], | |
| sessionsResponses: [OpenClawChatSessionsListResponse] = [], | |
| modelResponses: [[OpenClawChatModelChoice]] = [], | |
| resetSessionHook: ( (String) async throws -> Void)? = nil, | |
| setSessionModelHook: ( (String?) async throws -> Void)? = nil, | |
| setSessionThinkingHook: ( (String) async throws -> Void)? = nil) | |
| { | |
| self.historyResponses = historyResponses | |
| self.sessionsResponses = sessionsResponses | |
| self.modelResponses = modelResponses | |
| self.resetSessionHook = resetSessionHook | |
| self.setSessionModelHook = setSessionModelHook | |
| self.setSessionThinkingHook = setSessionThinkingHook | |
| var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation! | |
| self.stream = AsyncStream { c in | |
| cont = c | |
| } | |
| self.continuation = cont | |
| } | |
| func events() -> AsyncStream<OpenClawChatTransportEvent> { | |
| self.stream | |
| } | |
| func setActiveSessionKey(_: String) async throws {} | |
| func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { | |
| let idx = await self.state.historyCallCount | |
| await self.state.setHistoryCallCount(idx + 1) | |
| 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.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.sessionsCallCount | |
| await self.state.setSessionsCallCount(idx + 1) | |
| 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.modelsCallCount | |
| await self.state.setModelsCallCount(idx + 1) | |
| 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 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 { | |
| true | |
| } | |
| func emit(_ evt: OpenClawChatTransportEvent) { | |
| self.continuation.yield(evt) | |
| } | |
| func lastSentRunId() async -> String? { | |
| let ids = await self.state.sentRunIds | |
| return ids.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 patchedThinkingLevels() async -> [String] { | |
| await self.state.patchedThinkingLevels | |
| } | |
| func resetSessionKeys() async -> [String] { | |
| await self.state.resetSessionKeys | |
| } | |
| } | |
| extension TestChatTransportState { | |
| fileprivate func setHistoryCallCount(_ v: Int) { | |
| self.historyCallCount = v | |
| } | |
| fileprivate func setSessionsCallCount(_ v: Int) { | |
| self.sessionsCallCount = v | |
| } | |
| fileprivate func setModelsCallCount(_ v: Int) { | |
| self.modelsCallCount = v | |
| } | |
| fileprivate func sentRunIdsAppend(_ v: String) { | |
| self.sentRunIds.append(v) | |
| } | |
| fileprivate func abortedRunIdsAppend(_ v: String) { | |
| self.abortedRunIds.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) | |
| } | |
| } | |
| struct ChatViewModelTests { | |
| func streamsAssistantAndClearsOnFinal() 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 } } | |
| emitAssistantText(transport: transport, runId: sessionId, text: "streaming…") | |
| try await waitUntil("assistant stream visible") { | |
| await MainActor.run { vm.streamingAssistantText == "streaming…" } | |
| } | |
| emitToolStart(transport: transport, runId: sessionId) | |
| try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } | |
| let runId = try #require(await transport.lastSentRunId()) | |
| 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 }) | |
| } | |
| func acceptsCanonicalSessionKeyEventsForOwnPendingRun() 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 #require(await transport.lastSentRunId()) | |
| 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" }) } | |
| } | |
| } | |
| func acceptsCanonicalSessionKeyEventsForExternalRuns() 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 } | |
| } | |
| } | |
| func preservesMessageIDsAcrossHistoryRefreshes() 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) | |
| } | |
| func clearsStreamingOnExternalFinalEvent() 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 }) | |
| } | |
| func seqGapClearsPendingRunsAndAutoRefreshesHistory() 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 }) | |
| } | |
| func sessionChoicesPreferMainAndRecent() 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"]) | |
| } | |
| func sessionChoicesIncludeCurrentWhenMissing() 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"]) | |
| } | |
| func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() 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"]) | |
| } | |
| func sessionChoicesHideInternalOnboardingSession() 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"]) | |
| } | |
| func resetTriggerResetsSessionAndReloadsHistory() 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 = "/new" | |
| 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) | |
| } | |
| func bootstrapsModelSelectionFromSessionAndDefaults() 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") | |
| } | |
| func selectingDefaultModelPatchesNilAndUpdatesSelection() 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) | |
| } | |
| func selectingProviderQualifiedModelDisambiguatesDuplicateModelIDs() 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"] | |
| } | |
| } | |
| func slashModelIDsStayProviderQualifiedInSelectionAndPatch() 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"] | |
| } | |
| } | |
| func staleModelPatchCompletionsDoNotOverwriteNewerSelection() 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") | |
| } | |
| func sendWaitsForInFlightModelPatchToFinish() 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"]) | |
| } | |
| func failedLatestModelSelectionDoesNotReplayAfterOlderCompletionFinishes() 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"]) | |
| } | |
| func failedLatestModelSelectionRestoresEarlierSuccessWithoutReplay() 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"]) | |
| } | |
| func switchingSessionsIgnoresLateModelPatchCompletionFromPreviousSession() 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) | |
| } | |
| func lateModelCompletionDoesNotReplayCurrentSessionSelectionIntoPreviousSession() 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"]) | |
| } | |
| func explicitThinkingLevelWinsOverHistoryAndPersistsChanges() 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"]) | |
| } | |
| func serverProvidedThinkingLevelsOutsideMenuArePreservedForSend() 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"] | |
| } | |
| } | |
| func staleThinkingPatchCompletionReappliesLatestSelection() 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") | |
| } | |
| func clearsStreamingOnExternalErrorEvent() 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 } } | |
| } | |
| func stripsInboundMetadataFromHistoryMessages() 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?") | |
| } | |
| func abortRequestsDoNotClearPendingUntilAbortedEvent() 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 #require(await transport.lastSentRunId()) | |
| 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 } } | |
| } | |
| } | |