diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 858e2fc89ba..5d5b63c3bd7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -60,6 +60,9 @@ public final class OpenClawChatViewModel { private var nextThinkingSelectionRequestID: UInt64 = 0 private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:] private var latestThinkingLevelsBySession: [String: String] = [:] + private var isCompacting = false + private var lastCompactAt: Date? + private let compactCooldown: TimeInterval = 60 private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] { didSet { @@ -630,9 +633,26 @@ public final class OpenClawChatViewModel { } private func performCompact() async { + guard !self.isCompacting else { return } + guard !self.isSending, self.pendingRuns.isEmpty, !self.isAborting else { + self.errorText = "Wait for the current response before compacting the session." + return + } + if let lastCompactAt, + Date().timeIntervalSince(lastCompactAt) < self.compactCooldown + { + self.errorText = "Please wait before compacting this session again." + return + } + + self.isCompacting = true + self.lastCompactAt = Date() self.isLoading = true self.errorText = nil - defer { self.isLoading = false } + defer { + self.isLoading = false + self.isCompacting = false + } do { try await self.transport.compactSession(sessionKey: self.sessionKey) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index fd357a2d16a..903dd6607ee 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -989,6 +989,50 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.") } + @Test func compactTriggerIgnoresConcurrentAndImmediateRepeatRequests() async throws { + let before = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "before compact", timestamp: 1), + ]) + let after = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "after compact", timestamp: 2), + ]) + let gate = AsyncGate() + let (transport, vm) = await makeViewModel( + historyResponses: [before, after], + compactSessionHook: { _ in + await gate.wait() + }) + try await loadAndWaitBootstrap(vm: vm) + + await MainActor.run { + vm.input = "/compact" + vm.send() + vm.input = "/compact" + vm.send() + } + + try await waitUntil("single compact request issued") { + await transport.compactSessionKeys() == ["main"] + } + #expect(await MainActor.run { vm.errorText } == nil) + + await gate.open() + try await waitUntil("history reloaded after compact") { + await MainActor.run { vm.messages.first?.content.first?.text == "after compact" } + } + + await MainActor.run { + vm.input = "/compact" + vm.send() + } + + try await Task.sleep(for: .milliseconds(50)) + #expect(await transport.compactSessionKeys() == ["main"]) + #expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.") + } + @Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload()