import ClawdbotKit import Foundation import Testing @testable import ClawdbotChatUI private struct TimeoutError: Error, CustomStringConvertible { let label: String var description: String { "Timeout waiting for: \(self.label)" } } private func waitUntil( _ label: String, timeoutSeconds: Double = 2.0, pollMs: UInt64 = 10, _ condition: @escaping @Sendable () async -> Bool) async throws { let deadline = Date().addingTimeInterval(timeoutSeconds) while Date() < deadline { if await condition() { return } try await Task.sleep(nanoseconds: pollMs * 1_000_000) } throw TimeoutError(label: label) } private actor TestChatTransportState { var historyCallCount: Int = 0 var sessionsCallCount: Int = 0 var sentRunIds: [String] = [] var abortedRunIds: [String] = [] } private final class TestChatTransport: @unchecked Sendable, ClawdbotChatTransport { private let state = TestChatTransportState() private let historyResponses: [ClawdbotChatHistoryPayload] private let sessionsResponses: [ClawdbotChatSessionsListResponse] private let stream: AsyncStream private let continuation: AsyncStream.Continuation init( historyResponses: [ClawdbotChatHistoryPayload], sessionsResponses: [ClawdbotChatSessionsListResponse] = []) { self.historyResponses = historyResponses self.sessionsResponses = sessionsResponses var cont: AsyncStream.Continuation! self.stream = AsyncStream { c in cont = c } self.continuation = cont } func events() -> AsyncStream { self.stream } func setActiveSessionKey(_: String) async throws {} func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload { 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 ?? ClawdbotChatHistoryPayload( sessionKey: sessionKey, sessionId: nil, messages: [], thinkingLevel: "off") } func sendMessage( sessionKey _: String, message _: String, thinking _: String, idempotencyKey: String, attachments _: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse { await self.state.sentRunIdsAppend(idempotencyKey) return ClawdbotChatSendResponse(runId: idempotencyKey, status: "ok") } func abortRun(sessionKey _: String, runId: String) async throws { await self.state.abortedRunIdsAppend(runId) } func listSessions(limit _: Int?) async throws -> ClawdbotChatSessionsListResponse { 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 ?? ClawdbotChatSessionsListResponse( ts: nil, path: nil, count: 0, defaults: nil, sessions: []) } func requestHealth(timeoutMs _: Int) async throws -> Bool { true } func emit(_ evt: ClawdbotChatTransportEvent) { 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 } } extension TestChatTransportState { fileprivate func setHistoryCallCount(_ v: Int) { self.historyCallCount = v } fileprivate func setSessionsCallCount(_ v: Int) { self.sessionsCallCount = v } fileprivate func sentRunIdsAppend(_ v: String) { self.sentRunIds.append(v) } fileprivate func abortedRunIdsAppend(_ v: String) { self.abortedRunIds.append(v) } } @Suite struct ChatViewModelTests { @Test func streamsAssistantAndClearsOnFinal() async throws { let sessionId = "sess-main" let history1 = ClawdbotChatHistoryPayload( sessionKey: "main", sessionId: sessionId, messages: [], thinkingLevel: "off") let history2 = ClawdbotChatHistoryPayload( sessionKey: "main", sessionId: sessionId, messages: [ AnyCodable([ "role": "assistant", "content": [["type": "text", "text": "final answer"]], "timestamp": Date().timeIntervalSince1970 * 1000, ]), ], thinkingLevel: "off") let transport = TestChatTransport(historyResponses: [history1, history2]) let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) } await MainActor.run { vm.load() } try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } await MainActor.run { vm.input = "hi" vm.send() } try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } transport.emit( .agent( ClawdbotAgentEventPayload( runId: sessionId, seq: 1, stream: "assistant", ts: Int(Date().timeIntervalSince1970 * 1000), data: ["text": AnyCodable("streaming…")]))) try await waitUntil("assistant stream visible") { await MainActor.run { vm.streamingAssistantText == "streaming…" } } transport.emit( .agent( ClawdbotAgentEventPayload( runId: sessionId, seq: 2, stream: "tool", ts: Int(Date().timeIntervalSince1970 * 1000), data: [ "phase": AnyCodable("start"), "name": AnyCodable("demo"), "toolCallId": AnyCodable("t1"), "args": AnyCodable(["x": 1]), ]))) try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } let runId = try #require(await transport.lastSentRunId()) transport.emit( .chat( ClawdbotChatEventPayload( 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 clearsStreamingOnExternalFinalEvent() async throws { let sessionId = "sess-main" let history = ClawdbotChatHistoryPayload( sessionKey: "main", sessionId: sessionId, messages: [], thinkingLevel: "off") let transport = TestChatTransport(historyResponses: [history, history]) let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) } await MainActor.run { vm.load() } try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } transport.emit( .agent( ClawdbotAgentEventPayload( runId: sessionId, seq: 1, stream: "assistant", ts: Int(Date().timeIntervalSince1970 * 1000), data: ["text": AnyCodable("external stream")]))) transport.emit( .agent( ClawdbotAgentEventPayload( runId: sessionId, seq: 2, stream: "tool", ts: Int(Date().timeIntervalSince1970 * 1000), data: [ "phase": AnyCodable("start"), "name": AnyCodable("demo"), "toolCallId": AnyCodable("t1"), "args": AnyCodable(["x": 1]), ]))) 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 } } transport.emit( .chat( ClawdbotChatEventPayload( runId: "other-run", sessionKey: "main", state: "final", message: nil, errorMessage: nil))) try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } @Test 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 = ClawdbotChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", messages: [], thinkingLevel: "off") let sessions = ClawdbotChatSessionsListResponse( ts: now, path: nil, count: 4, defaults: nil, sessions: [ ClawdbotChatSessionEntry( key: "recent-1", kind: nil, displayName: nil, 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, model: nil, contextTokens: nil), ClawdbotChatSessionEntry( key: "main", kind: nil, displayName: nil, surface: nil, subject: nil, room: nil, space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, abortedLastRun: nil, thinkingLevel: nil, verboseLevel: nil, inputTokens: nil, outputTokens: nil, totalTokens: nil, model: nil, contextTokens: nil), ClawdbotChatSessionEntry( key: "recent-2", kind: nil, displayName: nil, 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, model: nil, contextTokens: nil), ClawdbotChatSessionEntry( key: "old-1", kind: nil, displayName: nil, surface: nil, subject: nil, room: nil, space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, abortedLastRun: nil, thinkingLevel: nil, verboseLevel: nil, inputTokens: nil, outputTokens: nil, totalTokens: nil, model: nil, contextTokens: nil), ]) let transport = TestChatTransport( historyResponses: [history], sessionsResponses: [sessions]) let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) } 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 sessionChoicesIncludeCurrentWhenMissing() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (30 * 60 * 1000) let history = ClawdbotChatHistoryPayload( sessionKey: "custom", sessionId: "sess-custom", messages: [], thinkingLevel: "off") let sessions = ClawdbotChatSessionsListResponse( ts: now, path: nil, count: 1, defaults: nil, sessions: [ ClawdbotChatSessionEntry( key: "main", kind: nil, displayName: nil, 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, model: nil, contextTokens: nil), ]) let transport = TestChatTransport( historyResponses: [history], sessionsResponses: [sessions]) let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "custom", transport: transport) } 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 clearsStreamingOnExternalErrorEvent() async throws { let sessionId = "sess-main" let history = ClawdbotChatHistoryPayload( sessionKey: "main", sessionId: sessionId, messages: [], thinkingLevel: "off") let transport = TestChatTransport(historyResponses: [history, history]) let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) } await MainActor.run { vm.load() } try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } transport.emit( .agent( ClawdbotAgentEventPayload( runId: sessionId, seq: 1, stream: "assistant", ts: Int(Date().timeIntervalSince1970 * 1000), data: ["text": AnyCodable("external stream")]))) try await waitUntil("streaming active") { await MainActor.run { vm.streamingAssistantText == "external stream" } } transport.emit( .chat( ClawdbotChatEventPayload( runId: "other-run", sessionKey: "main", state: "error", message: nil, errorMessage: "boom"))) try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } } @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { let sessionId = "sess-main" let history = ClawdbotChatHistoryPayload( sessionKey: "main", sessionId: sessionId, messages: [], thinkingLevel: "off") let transport = TestChatTransport(historyResponses: [history, history]) let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) } await MainActor.run { vm.load() } try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } await MainActor.run { vm.input = "hi" vm.send() } 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( ClawdbotChatEventPayload( runId: runId, sessionKey: "main", state: "aborted", message: nil, errorMessage: nil))) try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } } }