openclaw / apps /shared /OpenClawKit /Tests /OpenClawKitTests /ChatViewModelSessionActionTests.swift
Download apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 62.6 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift
- Command line
-
hf download hf://SaylorTwift/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift
-
curl -L -o ChatViewModelSessionActionTests.swift https://huggingface.co/SaylorTwift/openclaw/resolve/main/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift
62.6 kB
| import Foundation | |
| import OpenClawKit | |
| import Testing | |
| @testable import OpenClawChatUI | |
| private func makeSessionActionOutboxDirectory() throws -> URL { | |
| let directory = FileManager.default.temporaryDirectory | |
| .appendingPathComponent("chat-session-action-tests-\(UUID().uuidString)", isDirectory: true) | |
| try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | |
| return directory | |
| } | |
| private func sessionActionOutboxCommand( | |
| id: String, | |
| text: String) -> OpenClawChatOutboxCommand | |
| { | |
| OpenClawChatOutboxCommand( | |
| id: id, | |
| sessionKey: "main", | |
| text: text, | |
| thinking: "off", | |
| createdAt: Date().timeIntervalSince1970, | |
| status: .queued, | |
| retryCount: 0, | |
| lastError: nil) | |
| } | |
| private actor LegacyForkTransportState { | |
| var parentKeys: [String] = [] | |
| func record(_ parentKey: String) { | |
| self.parentKeys.append(parentKey) | |
| } | |
| } | |
| private final class LegacyForkTransport: @unchecked Sendable, OpenClawChatTransport { | |
| let state = LegacyForkTransportState() | |
| func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload { | |
| throw CancellationError() | |
| } | |
| func sendMessage( | |
| sessionKey _: String, | |
| message _: String, | |
| thinking _: String, | |
| idempotencyKey _: String, | |
| attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse | |
| { | |
| throw CancellationError() | |
| } | |
| func forkSession(parentKey: String) async throws -> String { | |
| await self.state.record(parentKey) | |
| return "legacy-child" | |
| } | |
| func requestHealth(timeoutMs _: Int) async throws -> Bool { | |
| false | |
| } | |
| func events() -> AsyncStream<OpenClawChatTransportEvent> { | |
| AsyncStream { $0.finish() } | |
| } | |
| } | |
| private actor SessionActionTransportState { | |
| var forkedParentKeys: [String] = [] | |
| var forkedFromLastCompleted: [Bool] = [] | |
| var rewoundMessages: [(sessionKey: String, entryID: String)] = [] | |
| var forkedMessages: [(sessionKey: String, entryID: String)] = [] | |
| var branchListSessionKeys: [String] = [] | |
| var branchListCallCount = 0 | |
| var switchedBranches: [(sessionKey: String, leafEntryID: String)] = [] | |
| var sentSessionKeys: [String] = [] | |
| var historySessionKeys: [String] = [] | |
| var historyCallCount = 0 | |
| var patchedKeys: [String] = [] | |
| var patchIdentities: [(key: String, expectedSessionID: String?)] = [] | |
| var deletedKeys: [String] = [] | |
| var groupPuts: [[String]] = [] | |
| var createdKeys: [String] = [] | |
| var createdAgentIDs: [String?] = [] | |
| var createdParentKeys: [String?] = [] | |
| var resetSessionKeys: [String] = [] | |
| var sessionListRequestCount = 0 | |
| func recordFork(_ key: String, fromLastCompleted: Bool) { | |
| self.forkedParentKeys.append(key) | |
| self.forkedFromLastCompleted.append(fromLastCompleted) | |
| } | |
| func recordRewind(sessionKey: String, entryID: String) { | |
| self.rewoundMessages.append((sessionKey, entryID)) | |
| } | |
| func recordForkAtMessage(sessionKey: String, entryID: String) { | |
| self.forkedMessages.append((sessionKey, entryID)) | |
| } | |
| func recordBranchList(_ sessionKey: String) -> Int { | |
| self.branchListSessionKeys.append(sessionKey) | |
| defer { self.branchListCallCount += 1 } | |
| return self.branchListCallCount | |
| } | |
| func recordBranchSwitch(sessionKey: String, leafEntryID: String) { | |
| self.switchedBranches.append((sessionKey, leafEntryID)) | |
| } | |
| func recordSend(sessionKey: String) { | |
| self.sentSessionKeys.append(sessionKey) | |
| } | |
| func recordHistory(_ sessionKey: String) -> Int { | |
| self.historySessionKeys.append(sessionKey) | |
| defer { self.historyCallCount += 1 } | |
| return self.historyCallCount | |
| } | |
| func recordPatch(_ key: String, expectedSessionID: String?) { | |
| self.patchedKeys.append(key) | |
| self.patchIdentities.append((key: key, expectedSessionID: expectedSessionID)) | |
| } | |
| func recordGroupPut(_ names: [String]) { | |
| self.groupPuts.append(names) | |
| } | |
| func recordDelete(_ key: String) { | |
| self.deletedKeys.append(key) | |
| } | |
| func recordCreate(key: String, agentID: String?, parentKey: String?) -> Int { | |
| let index = self.createdKeys.count | |
| self.createdKeys.append(key) | |
| self.createdAgentIDs.append(agentID) | |
| self.createdParentKeys.append(parentKey) | |
| return index | |
| } | |
| func recordReset(_ sessionKey: String) { | |
| self.resetSessionKeys.append(sessionKey) | |
| } | |
| func recordSessionListRequest() { | |
| self.sessionListRequestCount += 1 | |
| } | |
| } | |
| /// Signals the exact suspension point before an action completes, then holds it so | |
| /// navigation can advance deterministically before the stale result resumes. | |
| private struct SessionActionCompletionGate: Sendable { | |
| private let startedStream: AsyncStream<Void> | |
| private let startedContinuation: AsyncStream<Void>.Continuation | |
| private let releaseStream: AsyncStream<Void> | |
| private let releaseContinuation: AsyncStream<Void>.Continuation | |
| init() { | |
| let started = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1)) | |
| self.startedStream = started.stream | |
| self.startedContinuation = started.continuation | |
| let release = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1)) | |
| self.releaseStream = release.stream | |
| self.releaseContinuation = release.continuation | |
| } | |
| func suspendCompletion() async { | |
| self.startedContinuation.yield() | |
| var iterator = self.releaseStream.makeAsyncIterator() | |
| _ = await iterator.next() | |
| } | |
| func waitUntilStarted() async -> Bool { | |
| var iterator = self.startedStream.makeAsyncIterator() | |
| return await iterator.next() != nil | |
| } | |
| func release() { | |
| self.releaseContinuation.yield() | |
| } | |
| } | |
| private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTransport { | |
| private let state = SessionActionTransportState() | |
| private let createGate: SessionActionCompletionGate? | |
| private let resetGate: SessionActionCompletionGate? | |
| private let createIsUnsupported: Bool | |
| private let forkGate: SessionActionCompletionGate? | |
| private let rewindGate: SessionActionCompletionGate? | |
| private let forkAtMessageGate: SessionActionCompletionGate? | |
| private let branchSwitchGate: SessionActionCompletionGate? | |
| private let branchListGates: [SessionActionCompletionGate] | |
| private let rewindEditorText: String? | |
| private let rewindEditorAttachments: [OpenClawChatEditorAttachment]? | |
| private let forkAtMessageSessionKey: String | |
| private let forkAtMessageEditorText: String? | |
| private let forkAtMessageEditorAttachments: [OpenClawChatEditorAttachment]? | |
| private let branches: [OpenClawChatSessionBranch] | |
| private let branchListResponses: [[OpenClawChatSessionBranch]] | |
| private let branchListFailureIndices: Set<Int> | |
| private let historyGates: [Int: SessionActionCompletionGate] | |
| private let historyFailureIndices: Set<Int> | |
| private let sendSucceeds: Bool | |
| init( | |
| createGate: SessionActionCompletionGate? = nil, | |
| resetGate: SessionActionCompletionGate? = nil, | |
| createIsUnsupported: Bool = false, | |
| forkGate: SessionActionCompletionGate? = nil, | |
| rewindGate: SessionActionCompletionGate? = nil, | |
| forkAtMessageGate: SessionActionCompletionGate? = nil, | |
| branchSwitchGate: SessionActionCompletionGate? = nil, | |
| branchListGates: [SessionActionCompletionGate] = [], | |
| rewindEditorText: String? = "rewound draft", | |
| rewindEditorAttachments: [OpenClawChatEditorAttachment]? = nil, | |
| forkAtMessageSessionKey: String = "forked-at-message", | |
| forkAtMessageEditorText: String? = "forked draft", | |
| forkAtMessageEditorAttachments: [OpenClawChatEditorAttachment]? = nil, | |
| branches: [OpenClawChatSessionBranch] = [], | |
| branchListResponses: [[OpenClawChatSessionBranch]] = [], | |
| branchListFailureIndices: Set<Int> = [], | |
| historyGates: [Int: SessionActionCompletionGate] = [:], | |
| historyFailureIndices: Set<Int> = [], | |
| sendSucceeds: Bool = false) | |
| { | |
| self.createGate = createGate | |
| self.resetGate = resetGate | |
| self.createIsUnsupported = createIsUnsupported | |
| self.forkGate = forkGate | |
| self.rewindGate = rewindGate | |
| self.forkAtMessageGate = forkAtMessageGate | |
| self.branchSwitchGate = branchSwitchGate | |
| self.branchListGates = branchListGates | |
| self.rewindEditorText = rewindEditorText | |
| self.rewindEditorAttachments = rewindEditorAttachments | |
| self.forkAtMessageSessionKey = forkAtMessageSessionKey | |
| self.forkAtMessageEditorText = forkAtMessageEditorText | |
| self.forkAtMessageEditorAttachments = forkAtMessageEditorAttachments | |
| self.branches = branches | |
| self.branchListResponses = branchListResponses | |
| self.branchListFailureIndices = branchListFailureIndices | |
| self.historyGates = historyGates | |
| self.historyFailureIndices = historyFailureIndices | |
| self.sendSucceeds = sendSucceeds | |
| } | |
| func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { | |
| let callIndex = await self.state.recordHistory(sessionKey) | |
| await self.historyGates[callIndex]?.suspendCompletion() | |
| if self.historyFailureIndices.contains(callIndex) { | |
| throw NSError( | |
| domain: "SessionActionTransport", | |
| code: 2, | |
| userInfo: [NSLocalizedDescriptionKey: "history unavailable"]) | |
| } | |
| return OpenClawChatHistoryPayload( | |
| sessionKey: sessionKey, | |
| sessionId: "session-\(sessionKey)", | |
| messages: [], | |
| thinkingLevel: "off") | |
| } | |
| func sendMessage( | |
| sessionKey: String, | |
| message _: String, | |
| thinking _: String, | |
| idempotencyKey: String, | |
| attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse | |
| { | |
| await self.state.recordSend(sessionKey: sessionKey) | |
| if self.sendSucceeds { | |
| return OpenClawChatSendResponse(runId: idempotencyKey, status: "accepted") | |
| } | |
| throw NSError(domain: "SessionActionTransport", code: 1) | |
| } | |
| func forkSession(parentKey: String, fromLastCompleted: Bool) async throws -> String { | |
| await self.state.recordFork(parentKey, fromLastCompleted: fromLastCompleted) | |
| await self.forkGate?.suspendCompletion() | |
| return "forked" | |
| } | |
| func rewindSession( | |
| sessionKey: String, | |
| entryId: String) async throws -> OpenClawChatRewindResponse | |
| { | |
| await self.state.recordRewind(sessionKey: sessionKey, entryID: entryId) | |
| await self.rewindGate?.suspendCompletion() | |
| return OpenClawChatRewindResponse( | |
| editorText: self.rewindEditorText, | |
| editorAttachments: self.rewindEditorAttachments) | |
| } | |
| func forkSessionAtMessage( | |
| sessionKey: String, | |
| entryId: String) async throws -> OpenClawChatForkAtMessageResponse | |
| { | |
| await self.state.recordForkAtMessage(sessionKey: sessionKey, entryID: entryId) | |
| await self.forkAtMessageGate?.suspendCompletion() | |
| return OpenClawChatForkAtMessageResponse( | |
| sessionKey: self.forkAtMessageSessionKey, | |
| editorText: self.forkAtMessageEditorText, | |
| editorAttachments: self.forkAtMessageEditorAttachments) | |
| } | |
| func listSessionBranches( | |
| sessionKey: String, | |
| agentID _: String?) async throws -> OpenClawChatSessionBranchesResponse | |
| { | |
| let callIndex = await self.state.recordBranchList(sessionKey) | |
| if self.branchListGates.indices.contains(callIndex) { | |
| await self.branchListGates[callIndex].suspendCompletion() | |
| } | |
| if self.branchListFailureIndices.contains(callIndex) { | |
| throw NSError(domain: "SessionActionTransport", code: 3) | |
| } | |
| let branches = self.branchListResponses.indices.contains(callIndex) | |
| ? self.branchListResponses[callIndex] | |
| : self.branches | |
| return OpenClawChatSessionBranchesResponse(branches: branches) | |
| } | |
| func switchSessionBranch(sessionKey: String, agentID _: String?, leafEntryId: String) async throws { | |
| await self.state.recordBranchSwitch(sessionKey: sessionKey, leafEntryID: leafEntryId) | |
| await self.branchSwitchGate?.suspendCompletion() | |
| } | |
| func patchSession( | |
| key: String, | |
| expectedSessionID: String?, | |
| label _: String??, | |
| category _: String??, | |
| color _: String?? = nil, | |
| pinned _: Bool?, | |
| archived _: Bool?, | |
| unread _: Bool?) async throws | |
| { | |
| await self.state.recordPatch(key, expectedSessionID: expectedSessionID) | |
| } | |
| func acquireSessionGroupsRouteLease() async -> OpenClawChatSessionGroupsRouteLease? { | |
| let state = self.state | |
| return OpenClawChatSessionGroupsRouteLease( | |
| listGroups: { | |
| OpenClawChatSessionGroupsResponse(groups: [ | |
| OpenClawChatSessionGroup(name: "Existing", position: 0), | |
| ]) | |
| }, | |
| putGroups: { names in | |
| await state.recordGroupPut(names) | |
| return OpenClawChatSessionGroupsMutationResponse( | |
| ok: true, | |
| groups: names.enumerated().map { | |
| OpenClawChatSessionGroup(name: $0.element, position: $0.offset) | |
| }, | |
| updatedSessions: nil) | |
| }, | |
| renameGroup: { _, _ in | |
| OpenClawChatSessionGroupsMutationResponse(ok: true, groups: [], updatedSessions: nil) | |
| }, | |
| deleteGroup: { _ in | |
| OpenClawChatSessionGroupsMutationResponse(ok: true, groups: [], updatedSessions: nil) | |
| }) | |
| } | |
| func acquireNewSessionRouteLease() async -> OpenClawChatNewSessionRouteLease? { | |
| let state = self.state | |
| let createGate = self.createGate | |
| let createIsUnsupported = self.createIsUnsupported | |
| return OpenClawChatNewSessionRouteLease( | |
| listAgents: { | |
| OpenClawChatAgentsListResponse( | |
| defaultId: "worker", | |
| agents: [OpenClawChatAgentChoice(id: "worker", workspaceGit: true)]) | |
| }, | |
| createSession: { key, _, agentID, parentKey, _, _ in | |
| let index = await state.recordCreate(key: key, agentID: agentID, parentKey: parentKey) | |
| if index == 0 { | |
| await createGate?.suspendCompletion() | |
| } | |
| if createIsUnsupported { | |
| throw NSError( | |
| domain: "OpenClawChatTransport", | |
| code: 0, | |
| userInfo: [NSLocalizedDescriptionKey: "sessions.create not supported by this transport"]) | |
| } | |
| return OpenClawChatCreateSessionResponse(ok: true, key: key, sessionId: nil) | |
| }) | |
| } | |
| func resetSession(sessionKey: String) async throws { | |
| await self.state.recordReset(sessionKey) | |
| await self.resetGate?.suspendCompletion() | |
| } | |
| func listSessions( | |
| limit _: Int?, | |
| search _: String?, | |
| archived _: Bool) async throws -> OpenClawChatSessionsListResponse | |
| { | |
| await self.state.recordSessionListRequest() | |
| throw NSError( | |
| domain: "OpenClawChatTransport", | |
| code: 0, | |
| userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"]) | |
| } | |
| func deleteSession(key: String) async throws { | |
| await self.state.recordDelete(key) | |
| } | |
| func requestHealth(timeoutMs _: Int) async throws -> Bool { | |
| true | |
| } | |
| func events() -> AsyncStream<OpenClawChatTransportEvent> { | |
| AsyncStream { $0.finish() } | |
| } | |
| func forkedParentKeys() async -> [String] { | |
| await self.state.forkedParentKeys | |
| } | |
| func stableForkFlags() async -> [Bool] { | |
| await self.state.forkedFromLastCompleted | |
| } | |
| func rewoundMessages() async -> [(sessionKey: String, entryID: String)] { | |
| await self.state.rewoundMessages | |
| } | |
| func forkedMessages() async -> [(sessionKey: String, entryID: String)] { | |
| await self.state.forkedMessages | |
| } | |
| func branchListSessionKeys() async -> [String] { | |
| await self.state.branchListSessionKeys | |
| } | |
| func switchedBranches() async -> [(sessionKey: String, leafEntryID: String)] { | |
| await self.state.switchedBranches | |
| } | |
| func sentSessionKeys() async -> [String] { | |
| await self.state.sentSessionKeys | |
| } | |
| func historySessionKeys() async -> [String] { | |
| await self.state.historySessionKeys | |
| } | |
| func patchedKeys() async -> [String] { | |
| await self.state.patchedKeys | |
| } | |
| func patchIdentities() async -> [(key: String, expectedSessionID: String?)] { | |
| await self.state.patchIdentities | |
| } | |
| func groupPuts() async -> [[String]] { | |
| await self.state.groupPuts | |
| } | |
| func deletedKeys() async -> [String] { | |
| await self.state.deletedKeys | |
| } | |
| func createdAgentIDs() async -> [String?] { | |
| await self.state.createdAgentIDs | |
| } | |
| func createdKeys() async -> [String] { | |
| await self.state.createdKeys | |
| } | |
| func createdParentKeys() async -> [String?] { | |
| await self.state.createdParentKeys | |
| } | |
| func resetSessionKeys() async -> [String] { | |
| await self.state.resetSessionKeys | |
| } | |
| func sessionListRequestCount() async -> Int { | |
| await self.state.sessionListRequestCount | |
| } | |
| } | |
| private actor BatchMutationProbe { | |
| private(set) var active = 0 | |
| private(set) var maximumActive = 0 | |
| private(set) var visited: [String] = [] | |
| func begin(_ key: String) { | |
| self.active += 1 | |
| self.maximumActive = max(self.maximumActive, self.active) | |
| self.visited.append(key) | |
| } | |
| func end() { | |
| self.active -= 1 | |
| } | |
| } | |
| private struct BatchTestError: LocalizedError { | |
| var errorDescription: String? { | |
| "rejected" | |
| } | |
| } | |
| struct ChatViewModelSessionActionTests { | |
| func `batch mutations continue after per-row failure with bounded fan-out`() async { | |
| let probe = BatchMutationProbe() | |
| let result = await ChatSessionBatchMutationRunner.run( | |
| keys: ["a", "b", "c", "d", "e"], | |
| maxConcurrent: 2) | |
| { key in | |
| await probe.begin(key) | |
| try? await Task.sleep(for: .milliseconds(10)) | |
| await probe.end() | |
| if key == "c" { throw BatchTestError() } | |
| } | |
| #expect(result.succeededKeys == ["a", "b", "d", "e"]) | |
| #expect(result.errorsByKey == ["c": "rejected"]) | |
| #expect(await probe.maximumActive == 2) | |
| #expect(await Set(probe.visited) == Set(["a", "b", "c", "d", "e"])) | |
| } | |
| func `batch mutation includes selected server-search entry outside live roster`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let searchResult = self.entry(key: "older-search-result") | |
| let result = await viewModel.performSessionBatch(sessions: [searchResult], action: .pin) | |
| #expect(result.succeededKeys == ["older-search-result"]) | |
| #expect(result.errorsByKey.isEmpty) | |
| #expect(await transport.patchedKeys() == ["older-search-result"]) | |
| } | |
| func `batch archive carries each observed identity and rejects missing identity`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let result = await viewModel.performSessionBatch( | |
| sessions: [ | |
| self.entry(key: "durable", sessionId: "session-durable"), | |
| self.entry(key: "missing"), | |
| ], | |
| action: .archive) | |
| #expect(result.succeededKeys == ["durable"]) | |
| #expect(result.errorsByKey["missing"] != nil) | |
| #expect(await transport.patchIdentities().map(\.key) == ["durable"]) | |
| #expect(await transport.patchIdentities().map(\.expectedSessionID) == ["session-durable"]) | |
| } | |
| func `group create lists and replaces through one captured route lease`() async throws { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let lease = try await viewModel.sessionGroupsRouteLease() | |
| let groups = try await viewModel.createSessionGroup(named: "New", using: lease) | |
| #expect(groups.map(\.name) == ["Existing", "New"]) | |
| #expect(await transport.groupPuts() == [["Existing", "New"]]) | |
| // Catalog-only mutations must bump the revision so sidebar group fetches | |
| // keyed on it refetch instead of staying stale until reconnect. | |
| #expect(viewModel.sessionGroupsRevision == 1) | |
| } | |
| func `remote group mutations bump the catalog revision`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = await MainActor.run { | |
| OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| } | |
| await MainActor.run { | |
| viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: nil, reason: "groups"))) | |
| viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: nil, reason: "unrelated"))) | |
| } | |
| #expect(await MainActor.run { viewModel.sessionGroupsRevision } == 1) | |
| } | |
| func `batch delete rejects current session while attachment owner is pinned`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "worker", transport: transport) | |
| viewModel.attachments = [OpenClawPendingAttachment( | |
| url: nil, | |
| data: Data([1]), | |
| fileName: "draft.png", | |
| mimeType: "image/png", | |
| preview: nil)] | |
| let result = await viewModel.performSessionBatch( | |
| sessions: [self.entry(key: "worker")], | |
| action: .delete) | |
| #expect(result.succeededKeys.isEmpty) | |
| #expect(result.errorsByKey["worker"] != nil) | |
| #expect(await transport.deletedKeys().isEmpty) | |
| } | |
| func `new session options list and create through one captured route lease`() async throws { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let lease = try await viewModel.newSessionRouteLease() | |
| let response = try await lease.listAgents() | |
| await viewModel.startNewSession( | |
| agentID: response?.defaultId ?? "", | |
| worktree: true, | |
| worktreeBaseRef: "main", | |
| using: lease) | |
| #expect(await transport.createdAgentIDs() == ["worker"]) | |
| } | |
| func `new session creation rejects a duplicate while its gateway mutation is in flight`() async throws { | |
| let createGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport(createGate: createGate) | |
| var observedSelections: [String] = [] | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| onSessionChanged: { observedSelections.append($0) }) | |
| let lease = try await viewModel.newSessionRouteLease() | |
| let firstCreate = Task { | |
| await viewModel.startNewSession(agentID: "", worktree: false, worktreeBaseRef: nil, using: lease) | |
| } | |
| guard await self.waitForForkStart(createGate) else { | |
| createGate.release() | |
| firstCreate.cancel() | |
| Issue.record("timed out waiting for session creation start signal") | |
| return | |
| } | |
| let duplicateCreated = await viewModel.startNewSession( | |
| agentID: "", | |
| worktree: false, | |
| worktreeBaseRef: nil, | |
| using: lease) | |
| #expect(duplicateCreated == false) | |
| #expect(await transport.createdKeys().count == 1) | |
| createGate.release() | |
| #expect(await firstCreate.value) | |
| #expect(await viewModel.sessionKey == (transport.createdKeys()).first) | |
| #expect(observedSelections == [viewModel.sessionKey]) | |
| } | |
| (arguments: [false, true]) | |
| func `stale new session completion cannot replace or reset newer navigation`( | |
| createIsUnsupported: Bool) async throws | |
| { | |
| let createGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport( | |
| createGate: createGate, | |
| createIsUnsupported: createIsUnsupported) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let lease = try await viewModel.newSessionRouteLease() | |
| let create = Task { | |
| await viewModel.startNewSession(agentID: "", worktree: false, worktreeBaseRef: nil, using: lease) | |
| } | |
| guard await self.waitForForkStart(createGate) else { | |
| createGate.release() | |
| create.cancel() | |
| Issue.record("timed out waiting for session creation start signal") | |
| return | |
| } | |
| viewModel.switchSession(to: "other") | |
| let newerSession = viewModel.currentSessionSnapshot() | |
| let newerHistoryGeneration = viewModel.lastIssuedHistoryRequestID | |
| createGate.release() | |
| let created = await create.value | |
| #expect(created == false) | |
| #expect(viewModel.currentSessionSnapshot() == newerSession) | |
| #expect(viewModel.lastIssuedHistoryRequestID == newerHistoryGeneration) | |
| #expect(viewModel.errorText == nil) | |
| #expect(await transport.createdKeys().count == 1) | |
| #expect(await transport.resetSessionKeys().isEmpty) | |
| } | |
| (arguments: [false, true]) | |
| func `new session completion preserves attachment ownership acquired during creation`( | |
| createIsUnsupported: Bool) async throws | |
| { | |
| let createGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport( | |
| createGate: createGate, | |
| createIsUnsupported: createIsUnsupported) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let lease = try await viewModel.newSessionRouteLease() | |
| let create = Task { | |
| await viewModel.startNewSession(agentID: "", worktree: false, worktreeBaseRef: nil, using: lease) | |
| } | |
| #expect(await self.waitForForkStart(createGate)) | |
| viewModel.beginAttachmentStaging() | |
| defer { viewModel.endAttachmentStaging() } | |
| createGate.release() | |
| #expect(await create.value == false) | |
| #expect(viewModel.sessionKey == "main" && viewModel.isAttachmentOwnerPinned) | |
| #expect(viewModel.errorText == | |
| "Remove attachments or wait for delivery to resolve before starting a new chat.") | |
| #expect(await transport.createdKeys().count == 1) | |
| #expect(await transport.resetSessionKeys().isEmpty) | |
| if !createIsUnsupported { | |
| try await waitUntil("committed session is discoverable after attachment ownership changes") { | |
| await transport.sessionListRequestCount() == 1 | |
| } | |
| } | |
| } | |
| func `navigation during unsupported new session reset preserves the newer selection`() async throws { | |
| let resetGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport(resetGate: resetGate, createIsUnsupported: true) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let lease = try await viewModel.newSessionRouteLease() | |
| let create = Task { | |
| await viewModel.startNewSession(agentID: "", worktree: false, worktreeBaseRef: nil, using: lease) | |
| } | |
| guard await self.waitForForkStart(resetGate) else { | |
| resetGate.release() | |
| create.cancel() | |
| Issue.record("timed out waiting for fallback reset start signal") | |
| return | |
| } | |
| viewModel.switchSession(to: "other") | |
| let newerSession = viewModel.currentSessionSnapshot() | |
| let newerHistoryGeneration = viewModel.lastIssuedHistoryRequestID | |
| resetGate.release() | |
| let created = await create.value | |
| #expect(created == false) | |
| #expect(viewModel.currentSessionSnapshot() == newerSession) | |
| #expect(viewModel.lastIssuedHistoryRequestID == newerHistoryGeneration) | |
| #expect(viewModel.errorText == nil) | |
| #expect(await transport.resetSessionKeys() == ["main"]) | |
| } | |
| func `unsupported create with advanced options fails without resetting`() async { | |
| // SessionActionTransport relies on the protocol's default createSession, | |
| // which throws the canonical unsupported error; the worktree request must | |
| // surface it instead of taking the plain-new reset fallback. | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let created = await viewModel.startNewSession(worktree: true) | |
| #expect(created == false) | |
| #expect(viewModel.sessionKey == "main") | |
| #expect(viewModel.errorText != nil) | |
| } | |
| func `ambiguous agent ownership omits the parent session`() async throws { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| // Roster entries must not decide the current agent: "main" is unscoped and | |
| // no active agent is set, so agent selection crosses an ownership boundary. | |
| viewModel.sessions = [self.entry(key: "agent:worker:main")] | |
| let lease = try await viewModel.newSessionRouteLease() | |
| await viewModel.startNewSession( | |
| agentID: "worker", | |
| worktree: false, | |
| worktreeBaseRef: nil, | |
| using: lease) | |
| #expect(await transport.createdParentKeys() == [nil]) | |
| } | |
| func `active agent identity preserves parent for an unscoped current key`() async throws { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| activeAgentId: "worker") | |
| let lease = try await viewModel.newSessionRouteLease() | |
| await viewModel.startNewSession( | |
| agentID: "worker", | |
| worktree: false, | |
| worktreeBaseRef: nil, | |
| using: lease) | |
| #expect(await transport.createdParentKeys() == ["main"]) | |
| } | |
| func `rewind seeds editor and refreshes history`() async { | |
| let imageData = Data("rewound image".utf8) | |
| let transport = SessionActionTransport( | |
| rewindEditorText: "edit this turn", | |
| rewindEditorAttachments: [ | |
| OpenClawChatEditorAttachment( | |
| mimeType: "image/png", | |
| data: imageData.base64EncodedString()), | |
| OpenClawChatEditorAttachment(mimeType: "image/png", data: "%%%"), | |
| ]) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.input = "old draft" | |
| viewModel.attachments = [OpenClawPendingAttachment( | |
| url: nil, | |
| data: Data("old image".utf8), | |
| fileName: "old.png", | |
| mimeType: "image/png", | |
| preview: nil)] | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| #expect(viewModel.input == "edit this turn") | |
| #expect(viewModel.attachments.count == 1) | |
| #expect(viewModel.attachments.first?.data == imageData) | |
| #expect(viewModel.attachments.first?.mimeType == "image/png") | |
| #expect(await transport.rewoundMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]]) | |
| #expect(await transport.historySessionKeys() == ["main"]) | |
| } | |
| func `rewind does not dispatch while busy`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.isSending = true | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| #expect(await transport.rewoundMessages().isEmpty) | |
| #expect(await transport.historySessionKeys().isEmpty) | |
| } | |
| func `rewind waits for current session outbox confirmation`() async throws { | |
| let directory = try makeSessionActionOutboxDirectory() | |
| defer { try? FileManager.default.removeItem(at: directory) } | |
| let databases = try OpenClawClientDatabases(directoryURL: directory) | |
| let store = databases.store(gatewayID: "gw-test") | |
| let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil) | |
| #expect(await store.updateLastActiveLeafEntryID("leaf-active", expectedEpoch: 0, for: scope)) | |
| #expect(await store.enqueueCommand(sessionActionOutboxCommand( | |
| id: "rewind-pending", | |
| text: "wait before rewind"))) | |
| let transport = SessionActionTransport(branches: self.branches()) | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| outbox: store) | |
| viewModel.restoreOutboxMessages(session: viewModel.currentSessionSnapshot()) | |
| #expect(await self.waitForOutboxRestore(viewModel)) | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| #expect(viewModel.canPerformMessageSessionAction == false) | |
| #expect(await transport.rewoundMessages().isEmpty) | |
| await viewModel.confirmOutboxCommandsNow(in: [self.confirmingMessage(commandID: "rewind-pending")]) | |
| #expect(viewModel.canPerformMessageSessionAction) | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| #expect(await transport.rewoundMessages().map { [$0.sessionKey, $0.entryID] } == [ | |
| ["main", "message-42"], | |
| ]) | |
| } | |
| func `fork at message waits for current session outbox confirmation`() async throws { | |
| let directory = try makeSessionActionOutboxDirectory() | |
| defer { try? FileManager.default.removeItem(at: directory) } | |
| let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-test") | |
| #expect(await store.enqueueCommand(sessionActionOutboxCommand( | |
| id: "fork-pending", | |
| text: "wait before fork"))) | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| outbox: store) | |
| viewModel.restoreOutboxMessages(session: viewModel.currentSessionSnapshot()) | |
| #expect(await self.waitForOutboxRestore(viewModel)) | |
| await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) | |
| #expect(viewModel.canPerformMessageSessionAction == false) | |
| #expect(await transport.forkedMessages().isEmpty) | |
| await viewModel.confirmOutboxCommandsNow(in: [self.confirmingMessage(commandID: "fork-pending")]) | |
| #expect(viewModel.canPerformMessageSessionAction) | |
| await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) | |
| #expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [ | |
| ["main", "message-42"], | |
| ]) | |
| } | |
| func `rewind bumps branch epoch and parks a racing enqueue`() async throws { | |
| let directory = try makeSessionActionOutboxDirectory() | |
| defer { try? FileManager.default.removeItem(at: directory) } | |
| let databases = try OpenClawClientDatabases(directoryURL: directory) | |
| let store = databases.store(gatewayID: "gw-test") | |
| let siblingStore = databases.store(gatewayID: "gw-test") | |
| let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil) | |
| #expect(await store.updateLastActiveLeafEntryID("leaf-active", expectedEpoch: 0, for: scope)) | |
| let rewindGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport( | |
| rewindGate: rewindGate, | |
| branches: self.branches(activeLeafEntryID: "leaf-new")) | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| outbox: store) | |
| viewModel.hasRestoredOutboxMessages = true | |
| let rewind = Task { | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| } | |
| guard await self.waitForForkStart(rewindGate) else { | |
| rewindGate.release() | |
| rewind.cancel() | |
| Issue.record("timed out waiting for rewind start signal") | |
| return | |
| } | |
| #expect(await siblingStore.enqueueCommand(sessionActionOutboxCommand( | |
| id: "racing-rewind", | |
| text: "belongs to the old transcript"))) | |
| #expect(await siblingStore.claimNextCommand() == nil) | |
| rewindGate.release() | |
| await rewind.value | |
| let state = try #require(await store.branchState(for: scope)) | |
| #expect(state.epoch == 1) | |
| #expect(state.lastActiveLeafEntryID == "leaf-new") | |
| #expect(state.switchPendingSince == nil) | |
| let racedCommand = try #require(await store.loadCommands().first) | |
| #expect(racedCommand.id == "racing-rewind") | |
| #expect(racedCommand.status == .failed) | |
| #expect(OpenClawChatSQLiteTranscriptCache.outboxDisplayError(racedCommand.lastError) == | |
| "Session branch changed; review and retry this message.") | |
| } | |
| func `rewind list failure clears lease and later reconcile delivers`() async throws { | |
| let directory = try makeSessionActionOutboxDirectory() | |
| defer { try? FileManager.default.removeItem(at: directory) } | |
| let databases = try OpenClawClientDatabases(directoryURL: directory) | |
| let store = databases.store(gatewayID: "gw-test") | |
| let siblingStore = databases.store(gatewayID: "gw-test") | |
| let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil) | |
| #expect(await store.updateLastActiveLeafEntryID("leaf-active", expectedEpoch: 0, for: scope)) | |
| let transport = SessionActionTransport( | |
| branches: self.branches(), | |
| branchListFailureIndices: [0], | |
| sendSucceeds: true) | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| outbox: store) | |
| viewModel.hasRestoredOutboxMessages = true | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| #expect(await store.branchState(for: scope)?.switchPendingSince == nil) | |
| #expect(await store.branchState(for: scope)?.needsReconciliation == true) | |
| #expect(viewModel.reconciledOutboxBranchScopes.contains(scope) == false) | |
| #expect(await store.enqueueCommand(sessionActionOutboxCommand( | |
| id: "after-rewind-list-failure", | |
| text: "send after reconcile"))) | |
| #expect(await siblingStore.claimNextCommand() == nil) | |
| viewModel.healthOK = true | |
| viewModel.readySessionMetadataGeneration = viewModel.sessionMetadataGeneration | |
| viewModel.flushOutboxIfNeeded() | |
| #expect(await self.waitForSend(transport)) | |
| #expect(await transport.branchListSessionKeys().suffix(2) == ["main", "main"]) | |
| #expect(await transport.sentSessionKeys() == ["main"]) | |
| #expect(await store.loadCommands().map(\.status) == [.awaitingConfirmation]) | |
| } | |
| func `branch refresh populates state`() async { | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| await viewModel.refreshSessionBranches() | |
| #expect(viewModel.sessionBranches == branches) | |
| #expect(viewModel.isLoadingSessionBranches == false) | |
| #expect(await transport.branchListSessionKeys() == ["main"]) | |
| } | |
| func `opening branch menu refreshes conversation stale metadata once`() async { | |
| let staleBranches = self.branches() | |
| let freshBranches = self.branches(activeLeafEntryID: "leaf-new") | |
| let transport = SessionActionTransport(branches: freshBranches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = staleBranches | |
| await viewModel.refreshSessionBranchesForMenuPresentation() | |
| #expect(viewModel.sessionBranches == freshBranches) | |
| #expect(await transport.branchListSessionKeys() == ["main"]) | |
| } | |
| func `branch message count uses localized singular and plural forms`() { | |
| #expect(OpenClawChatComposer.branchMessageCount(1) == "1 message") | |
| #expect(OpenClawChatComposer.branchMessageCount(2) == "2 messages") | |
| } | |
| func `branch refresh failure preserves cached branches`() async { | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branchListFailureIndices: [0]) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| await viewModel.refreshSessionBranches() | |
| #expect(viewModel.sessionBranches == branches) | |
| #expect(viewModel.isLoadingSessionBranches == false) | |
| #expect(await transport.branchListSessionKeys() == ["main"]) | |
| } | |
| func `read only branch refresh failure preserves replay eligibility`() async throws { | |
| let directory = try makeSessionActionOutboxDirectory() | |
| defer { try? FileManager.default.removeItem(at: directory) } | |
| let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-test") | |
| let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil) | |
| let transport = SessionActionTransport(branchListFailureIndices: [0]) | |
| let viewModel = OpenClawChatViewModel( | |
| sessionKey: "main", | |
| transport: transport, | |
| outbox: store) | |
| viewModel.reconciledOutboxBranchScopes.insert(scope) | |
| await viewModel.refreshSessionBranchesForMenuPresentation() | |
| #expect(viewModel.reconciledOutboxBranchScopes.contains(scope)) | |
| #expect(await store.branchState(for: scope)?.switchPendingSince == nil) | |
| } | |
| func `newer branch refresh supersedes an older response`() async { | |
| let firstGate = SessionActionCompletionGate() | |
| let oldBranches = self.branches() | |
| let newBranches = self.branches(activeLeafEntryID: "leaf-new") | |
| let transport = SessionActionTransport( | |
| branchListGates: [firstGate], | |
| branchListResponses: [oldBranches, newBranches]) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let firstRefresh = Task { await viewModel.refreshSessionBranches() } | |
| guard await self.waitForForkStart(firstGate) else { | |
| firstGate.release() | |
| firstRefresh.cancel() | |
| Issue.record("timed out waiting for branch list start signal") | |
| return | |
| } | |
| await viewModel.refreshSessionBranches() | |
| #expect(viewModel.sessionBranches == newBranches) | |
| firstGate.release() | |
| await firstRefresh.value | |
| #expect(viewModel.sessionBranches == newBranches) | |
| #expect(viewModel.isLoadingSessionBranches == false) | |
| #expect(await transport.branchListSessionKeys() == ["main", "main"]) | |
| } | |
| func `branch switch refreshes history and branch state`() async { | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| await viewModel.switchToBranch("leaf-new") | |
| #expect(await transport.switchedBranches().map { [$0.sessionKey, $0.leafEntryID] } == [ | |
| ["main", "leaf-new"], | |
| ]) | |
| #expect(await transport.historySessionKeys() == ["main"]) | |
| #expect(await transport.branchListSessionKeys() == ["main"]) | |
| #expect(viewModel.sessionBranches == branches) | |
| } | |
| (arguments: [false, true]) | |
| func `branch change failure funnels through full session reload`(remoteEvent: Bool) async { | |
| let historyReloadGate = SessionActionCompletionGate() | |
| let branchesReloadGate = SessionActionCompletionGate() | |
| let remoteConfirmationGate = SessionActionCompletionGate() | |
| let staleBranches = self.branches() | |
| let freshBranches = self.branches(activeLeafEntryID: "leaf-new") | |
| let transport = SessionActionTransport( | |
| branchListGates: remoteEvent | |
| ? [remoteConfirmationGate, branchesReloadGate] | |
| : [branchesReloadGate], | |
| branches: freshBranches, | |
| historyGates: [2: historyReloadGate], | |
| historyFailureIndices: [0, 1]) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = staleBranches | |
| viewModel.messages = [self.userMessage(entryID: "pre-switch")] | |
| if remoteEvent { | |
| viewModel.handleTransportEvent(.sessionsChanged(.init( | |
| sessionKey: "main", | |
| reason: "branch-switch"))) | |
| _ = await self.waitForForkStart(remoteConfirmationGate) | |
| remoteConfirmationGate.release() | |
| } else { | |
| await viewModel.switchToBranch("leaf-new") | |
| } | |
| let historyReloadStarted = await self.waitForForkStart(historyReloadGate) | |
| let branchesReloadStarted = await self.waitForForkStart(branchesReloadGate) | |
| #expect(historyReloadStarted) | |
| #expect(branchesReloadStarted) | |
| #expect(await transport.historySessionKeys() == ["main", "main", "main"]) | |
| #expect(await transport.branchListSessionKeys() == (remoteEvent ? ["main", "main"] : ["main"])) | |
| #expect(viewModel.messages.isEmpty) | |
| #expect(viewModel.sessionBranches.isEmpty) | |
| #expect(viewModel.hasAppliedLiveHistory == false) | |
| #expect(viewModel.isLoading) | |
| historyReloadGate.release() | |
| branchesReloadGate.release() | |
| let reloaded = await self.waitForBranchReload(viewModel, branches: freshBranches) | |
| #expect(reloaded) | |
| #expect(viewModel.sessionBranches.first(where: \.active)?.leafEntryId == "leaf-new") | |
| } | |
| func `branch switch does not dispatch while busy`() async { | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| viewModel.isSending = true | |
| await viewModel.switchToBranch("leaf-new") | |
| #expect(await transport.switchedBranches().isEmpty) | |
| #expect(await transport.historySessionKeys().isEmpty) | |
| #expect(await transport.branchListSessionKeys().isEmpty) | |
| } | |
| func `branch switch does not overlap an in flight switch`() async { | |
| let gate = SessionActionCompletionGate() | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| let firstSwitch = Task { await viewModel.switchToBranch("leaf-new") } | |
| guard await self.waitForForkStart(gate) else { | |
| gate.release() | |
| firstSwitch.cancel() | |
| Issue.record("timed out waiting for branch switch start signal") | |
| return | |
| } | |
| await viewModel.switchToBranch("leaf-new") | |
| #expect(await transport.switchedBranches().count == 1) | |
| gate.release() | |
| await firstSwitch.value | |
| } | |
| func `branch switch blocks sends and rewinds until refresh completes`() async { | |
| let gate = SessionActionCompletionGate() | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| viewModel.input = "new message" | |
| let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") } | |
| guard await self.waitForForkStart(gate) else { | |
| gate.release() | |
| branchSwitch.cancel() | |
| Issue.record("timed out waiting for branch switch start signal") | |
| return | |
| } | |
| #expect(viewModel.hasBlockingRunActivity) | |
| #expect(viewModel.canSend == false) | |
| viewModel.send() | |
| await viewModel.rewindToMessage(self.userMessage(entryID: "message-42")) | |
| await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) | |
| for _ in 0..<10 { | |
| await Task.yield() | |
| } | |
| #expect(await transport.sentSessionKeys().isEmpty) | |
| #expect(await transport.rewoundMessages().isEmpty) | |
| #expect(await transport.forkedMessages().isEmpty) | |
| gate.release() | |
| await branchSwitch.value | |
| #expect(viewModel.hasBlockingRunActivity == false) | |
| #expect(viewModel.canSend) | |
| } | |
| func `stale branch switch completion is ignored`() async { | |
| let gate = SessionActionCompletionGate() | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") } | |
| guard await self.waitForForkStart(gate) else { | |
| gate.release() | |
| branchSwitch.cancel() | |
| Issue.record("timed out waiting for branch switch start signal") | |
| return | |
| } | |
| viewModel.switchSession(to: "other") | |
| gate.release() | |
| await branchSwitch.value | |
| #expect(viewModel.sessionKey == "other") | |
| #expect(await transport.switchedBranches().map { [$0.sessionKey, $0.leafEntryID] } == [ | |
| ["main", "leaf-new"], | |
| ]) | |
| #expect(await transport.historySessionKeys().contains("main") == false) | |
| #expect(await transport.branchListSessionKeys().contains("main") == false) | |
| } | |
| func `navigation releases branch switch gate before stale completion`() async { | |
| let gate = SessionActionCompletionGate() | |
| let branches = self.branches() | |
| let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessionBranches = branches | |
| let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") } | |
| guard await self.waitForForkStart(gate) else { | |
| gate.release() | |
| branchSwitch.cancel() | |
| Issue.record("timed out waiting for branch switch start signal") | |
| return | |
| } | |
| viewModel.switchSession(to: "other") | |
| viewModel.input = "new session message" | |
| #expect(viewModel.hasBlockingRunActivity == false) | |
| #expect(viewModel.canSend) | |
| gate.release() | |
| await branchSwitch.value | |
| #expect(viewModel.sessionKey == "other") | |
| #expect(viewModel.hasBlockingRunActivity == false) | |
| #expect(viewModel.canSend) | |
| } | |
| func `fork at message switches and seeds editor`() async { | |
| let imageData = Data("forked image".utf8) | |
| let transport = SessionActionTransport( | |
| forkAtMessageSessionKey: "agent:main:forked", | |
| forkAtMessageEditorText: "continue here", | |
| forkAtMessageEditorAttachments: [OpenClawChatEditorAttachment( | |
| mimeType: "image/webp", | |
| data: imageData.base64EncodedString())]) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) | |
| #expect(viewModel.sessionKey == "agent:main:forked") | |
| #expect(viewModel.input == "continue here") | |
| #expect(viewModel.attachments.count == 1) | |
| #expect(viewModel.attachments.first?.data == imageData) | |
| #expect(viewModel.attachments.first?.mimeType == "image/webp") | |
| #expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]]) | |
| } | |
| func `fork at message completion does not override newer navigation`() async { | |
| let forkGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport( | |
| forkAtMessageGate: forkGate, | |
| forkAtMessageSessionKey: "agent:main:forked") | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let fork = Task { await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) } | |
| guard await self.waitForForkStart(forkGate) else { | |
| forkGate.release() | |
| fork.cancel() | |
| Issue.record("timed out waiting for fork start signal") | |
| return | |
| } | |
| viewModel.switchSession(to: "other") | |
| forkGate.release() | |
| await fork.value | |
| #expect(viewModel.sessionKey == "other") | |
| #expect(viewModel.input.isEmpty) | |
| #expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]]) | |
| } | |
| func `remote rewind refreshes current transcript only`() async throws { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "other", reason: "rewind"))) | |
| viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "main", reason: "rewind"))) | |
| try await waitUntil("remote rewind history request") { | |
| await transport.historySessionKeys().isEmpty == false | |
| } | |
| #expect(await transport.historySessionKeys() == ["main"]) | |
| } | |
| func `remote branch switch refreshes current transcript and branches only`() async throws { | |
| let transport = SessionActionTransport(branches: self.branches()) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.setReplyTarget(messageID: UUID(), text: "old branch", senderLabel: "User") | |
| viewModel.input = "new message" | |
| viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "other", reason: "branch-switch"))) | |
| #expect(viewModel.replyTarget != nil) | |
| #expect(viewModel.canSend) | |
| viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "main", reason: "branch-switch"))) | |
| #expect(viewModel.hasBlockingRunActivity) | |
| #expect(viewModel.canSend == false) | |
| try await waitUntil("remote branch switch branch list request") { | |
| await transport.branchListSessionKeys().isEmpty == false | |
| } | |
| let unlocked = await self.waitForBranchSwitchActivityToClear(viewModel) | |
| #expect(unlocked) | |
| #expect(viewModel.replyTarget == nil) | |
| #expect(await transport.historySessionKeys() == ["main"]) | |
| #expect(await transport.branchListSessionKeys() == ["main"]) | |
| } | |
| func `fork does not mutate gateway while session switching is blocked`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.attachments = [OpenClawPendingAttachment( | |
| url: nil, | |
| data: Data([1]), | |
| fileName: "draft.png", | |
| mimeType: "image/png", | |
| preview: nil)] | |
| await viewModel.forkSession(key: "main") | |
| let forkedKeys = await transport.forkedParentKeys() | |
| #expect(forkedKeys.isEmpty) | |
| #expect(viewModel.sessionKey == "main") | |
| #expect(viewModel.errorText == String( | |
| localized: "Remove attachments or wait for delivery to resolve before starting a new chat.")) | |
| } | |
| func `fork routes active sessions through the completed-message boundary`() async { | |
| let transport = SessionActionTransport() | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| viewModel.sessions = [self.entry(key: "main", hasActiveRun: true)] | |
| await viewModel.forkSession(key: "main") | |
| #expect(await transport.stableForkFlags() == [true]) | |
| } | |
| func `boundary-aware fork remains compatible with legacy transports`() async throws { | |
| let legacy = LegacyForkTransport() | |
| let transport: any OpenClawChatTransport = legacy | |
| let childKey = try await transport.forkSession( | |
| parentKey: "main", | |
| fromLastCompleted: true) | |
| #expect(childKey == "legacy-child") | |
| #expect(await legacy.state.parentKeys == ["main"]) | |
| } | |
| func `fork completion does not override newer navigation`() async { | |
| let forkGate = SessionActionCompletionGate() | |
| let transport = SessionActionTransport(forkGate: forkGate) | |
| let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) | |
| let fork = Task { await viewModel.forkSession(key: "main") } | |
| guard await self.waitForForkStart(forkGate) else { | |
| forkGate.release() | |
| fork.cancel() | |
| Issue.record("timed out waiting for fork start signal") | |
| return | |
| } | |
| viewModel.switchSession(to: "other") | |
| forkGate.release() | |
| await fork.value | |
| #expect(viewModel.sessionKey == "other") | |
| #expect(await transport.forkedParentKeys() == ["main"]) | |
| } | |
| private func waitForForkStart( | |
| _ gate: SessionActionCompletionGate, | |
| timeout: Duration = .seconds(15)) async -> Bool | |
| { | |
| // The stream controls ordering; this deadline only bounds a broken fake or call path. | |
| await withTaskGroup(of: Bool.self) { group in | |
| group.addTask { await gate.waitUntilStarted() } | |
| group.addTask { | |
| try? await Task.sleep(for: timeout) | |
| return false | |
| } | |
| let started = await group.next() ?? false | |
| group.cancelAll() | |
| return started | |
| } | |
| } | |
| private func waitForBranchSwitchActivityToClear( | |
| _ viewModel: OpenClawChatViewModel, | |
| timeout: Duration = .seconds(15)) async -> Bool | |
| { | |
| let clock = ContinuousClock() | |
| let deadline = clock.now + timeout | |
| while clock.now < deadline { | |
| if viewModel.hasBlockingRunActivity == false { | |
| return true | |
| } | |
| await Task.yield() | |
| } | |
| return false | |
| } | |
| private func waitForOutboxRestore( | |
| _ viewModel: OpenClawChatViewModel, | |
| timeout: Duration = .seconds(15)) async -> Bool | |
| { | |
| let clock = ContinuousClock() | |
| let deadline = clock.now + timeout | |
| while clock.now < deadline { | |
| if viewModel.hasRestoredOutboxMessages, | |
| viewModel.hasPendingOutboxCommandsForCurrentSession | |
| { | |
| return true | |
| } | |
| await Task.yield() | |
| } | |
| return false | |
| } | |
| private func waitForSend( | |
| _ transport: SessionActionTransport, | |
| timeout: Duration = .seconds(15)) async -> Bool | |
| { | |
| let clock = ContinuousClock() | |
| let deadline = clock.now + timeout | |
| while clock.now < deadline { | |
| if await transport.sentSessionKeys().isEmpty == false { | |
| return true | |
| } | |
| await Task.yield() | |
| } | |
| return false | |
| } | |
| private func waitForBranchReload( | |
| _ viewModel: OpenClawChatViewModel, | |
| branches: [OpenClawChatSessionBranch], | |
| timeout: Duration = .seconds(15)) async -> Bool | |
| { | |
| let clock = ContinuousClock() | |
| let deadline = clock.now + timeout | |
| while clock.now < deadline { | |
| if viewModel.sessionBranches == branches, !viewModel.isLoading { | |
| return true | |
| } | |
| await Task.yield() | |
| } | |
| return false | |
| } | |
| private func userMessage(entryID: String) -> OpenClawChatMessage { | |
| OpenClawChatMessage( | |
| role: "user", | |
| content: [], | |
| timestamp: nil, | |
| transcriptMessageID: entryID) | |
| } | |
| private func confirmingMessage(commandID: String) -> OpenClawChatMessage { | |
| OpenClawChatMessage( | |
| role: "user", | |
| content: [], | |
| timestamp: nil, | |
| idempotencyKey: "\(commandID):user") | |
| } | |
| private func branches(activeLeafEntryID: String = "leaf-active") -> [OpenClawChatSessionBranch] { | |
| [ | |
| OpenClawChatSessionBranch( | |
| leafEntryId: "leaf-active", | |
| headline: "Current path", | |
| messageCount: 4, | |
| updatedAt: "2026-07-19T12:00:00Z", | |
| active: activeLeafEntryID == "leaf-active"), | |
| OpenClawChatSessionBranch( | |
| leafEntryId: "leaf-new", | |
| headline: "Alternate path", | |
| messageCount: 2, | |
| updatedAt: nil, | |
| active: activeLeafEntryID == "leaf-new"), | |
| ] | |
| } | |
| private func entry( | |
| key: String, | |
| sessionId: String? = nil, | |
| hasActiveRun: Bool? = nil) -> OpenClawChatSessionEntry | |
| { | |
| OpenClawChatSessionEntry( | |
| key: key, | |
| kind: nil, | |
| displayName: nil, | |
| surface: nil, | |
| subject: nil, | |
| room: nil, | |
| space: nil, | |
| updatedAt: nil, | |
| sessionId: sessionId, | |
| systemSent: nil, | |
| abortedLastRun: nil, | |
| thinkingLevel: nil, | |
| verboseLevel: nil, | |
| inputTokens: nil, | |
| outputTokens: nil, | |
| totalTokens: nil, | |
| modelProvider: nil, | |
| model: nil, | |
| contextTokens: nil, | |
| hasActiveRun: hasActiveRun) | |
| } | |
| } | |