chore: rename project to clawdbot
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
@testable import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
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<ClawdbotChatTransportEvent>
|
||||
private let continuation: AsyncStream<ClawdbotChatTransportEvent>.Continuation
|
||||
|
||||
init(
|
||||
historyResponses: [ClawdbotChatHistoryPayload],
|
||||
sessionsResponses: [ClawdbotChatSessionsListResponse] = [])
|
||||
{
|
||||
self.historyResponses = historyResponses
|
||||
self.sessionsResponses = sessionsResponses
|
||||
var cont: AsyncStream<ClawdbotChatTransportEvent>.Continuation!
|
||||
self.stream = AsyncStream { c in
|
||||
cont = c
|
||||
}
|
||||
self.continuation = cont
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private extension TestChatTransportState {
|
||||
func setHistoryCallCount(_ v: Int) {
|
||||
self.historyCallCount = v
|
||||
}
|
||||
|
||||
func setSessionsCallCount(_ v: Int) {
|
||||
self.sessionsCallCount = v
|
||||
}
|
||||
|
||||
func sentRunIdsAppend(_ v: String) {
|
||||
self.sentRunIds.append(v)
|
||||
}
|
||||
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user