Webchat: handle bare /compact as session compaction

This commit is contained in:
scoootscooob
2026-03-24 10:23:41 -07:00
committed by scoootscooob
parent 01d3442246
commit 44e27c6092
4 changed files with 86 additions and 0 deletions

View File

@@ -28,6 +28,7 @@ public protocol OpenClawChatTransport: Sendable {
func setActiveSessionKey(_ sessionKey: String) async throws
func resetSession(sessionKey: String) async throws
func compactSession(sessionKey: String) async throws
}
extension OpenClawChatTransport {
@@ -40,6 +41,13 @@ extension OpenClawChatTransport {
userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
}
public func compactSession(sessionKey _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.compact not supported by this transport"])
}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",

View File

@@ -465,6 +465,7 @@ public final class OpenClawChatViewModel {
}
private static let resetTriggers: Set<String> = ["/new", "/reset", "/clear"]
private static let compactTriggers: Set<String> = ["/compact"]
private func performSend() async {
guard !self.isSending else { return }
@@ -476,6 +477,11 @@ public final class OpenClawChatViewModel {
await self.performReset()
return
}
if Self.compactTriggers.contains(trimmed.lowercased()) {
self.input = ""
await self.performCompact()
return
}
let sessionKey = self.sessionKey
@@ -623,6 +629,22 @@ public final class OpenClawChatViewModel {
await self.bootstrap()
}
private func performCompact() async {
self.isLoading = true
self.errorText = nil
defer { self.isLoading = false }
do {
try await self.transport.compactSession(sessionKey: self.sessionKey)
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("session compact failed \(error.localizedDescription, privacy: .public)")
return
}
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }

View File

@@ -224,6 +224,7 @@ private actor TestChatTransportState {
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var resetSessionKeys: [String] = []
var compactSessionKeys: [String] = []
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
@@ -237,6 +238,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
@@ -248,6 +250,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
@@ -255,6 +258,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.resetSessionHook = resetSessionHook
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
@@ -336,6 +340,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
}
}
func compactSession(sessionKey: String) async throws {
await self.state.compactSessionKeysAppend(sessionKey)
if let compactSessionHook = self.compactSessionHook {
try await compactSessionHook(sessionKey)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
@@ -375,6 +386,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func resetSessionKeys() async -> [String] {
await self.state.resetSessionKeys
}
func compactSessionKeys() async -> [String] {
await self.state.compactSessionKeys
}
}
extension TestChatTransportState {
@@ -413,6 +428,10 @@ extension TestChatTransportState {
fileprivate func resetSessionKeysAppend(_ v: String) {
self.resetSessionKeys.append(v)
}
fileprivate func compactSessionKeysAppend(_ v: String) {
self.compactSessionKeys.append(v)
}
}
@Suite struct ChatViewModelTests {
@@ -915,6 +934,36 @@ extension TestChatTransportState {
#expect(await transport.lastSentRunId() == nil)
}
@Test func compactTriggerCompactsSessionAndReloadsHistory() 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 (transport, vm) = await makeViewModel(historyResponses: [before, after])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact called") {
await transport.compactSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
#expect(await transport.lastSentRunId() == nil)
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()