openclaw / apps /shared /OpenClawKit /Tests /OpenClawKitTests /ChatViewModelOutboxSettingsTests.swift
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
b8370f5 verified
Raw History Blame Contribute Delete
14.1 kB
import Foundation
import OpenClawKit
import Testing
@testable import OpenClawChatUI
private actor SettingsPatchCounter {
private var count = 0
func increment() {
self.count += 1
}
func current() -> Int {
self.count
}
}
struct ChatViewModelOutboxSettingsTests {
@Test func `background replay uses its command owned session settings`() async throws {
let (store, _, databaseDirectory) = try makeOutboxStore()
defer { try? FileManager.default.removeItem(at: databaseDirectory) }
let expectation = OpenClawChatSessionSettingsExpectation(
permissionMode: .guarded,
toolOverrides: OpenClawChatSessionToolOverrides(webSearch: false))
#expect(await store.enqueueCommand(outboxTestCommand(
id: "background-settings",
text: "use captured authority",
createdAt: Date().timeIntervalSince1970,
sessionKey: "background",
expectedSessionSettings: expectation)))
let transport = OutboxTestTransport(
healthy: true,
sessions: [
outboxSessionEntry(key: "main", thinkingLevels: ["off"], permissionMode: .full),
outboxSessionEntry(key: "background", thinkingLevels: ["off"], permissionMode: .guarded),
],
supportsSessionSettingsCAS: true)
let vm = await makeOutboxViewModel(transport: transport, outbox: store)
await MainActor.run { vm.load() }
try await waitUntil("background command dispatch") {
await transport.state.sentMessages == ["use captured authority"]
}
#expect(await transport.state.sentSessionKeys == ["agent:main:background"])
#expect(await transport.state.sentSessionSettings.count == 1)
#expect(await transport.state.sentSessionSettings[0] == expectation)
}
@Test(arguments: [false, true])
func `settings failures remain retryable before outbox reload`(gatewayRejectsSettings: Bool) async throws {
let (store, _, databaseDirectory) = try makeOutboxStore()
defer { try? FileManager.default.removeItem(at: databaseDirectory) }
#expect(await store.enqueueCommand(outboxTestCommand(
id: "legacy-settings",
text: "review before replay",
createdAt: Date().timeIntervalSince1970,
expectedSessionSettings: gatewayRejectsSettings
? OpenClawChatSessionSettingsExpectation(permissionMode: nil, toolOverrides: nil)
: nil)))
let outbox = ScriptedOutbox(base: store)
let transport = OutboxTestTransport(
healthy: false,
sessions: [outboxSessionEntry(key: "main", thinkingLevels: ["off"])],
supportsSessionSettingsCAS: true)
await transport.state.update { $0.sendSettingsChanged = gatewayRejectsSettings }
let vm = await makeOutboxViewModel(transport: transport, outbox: outbox)
await MainActor.run { vm.load() }
try await waitUntil("offline bootstrap settled") {
await MainActor.run { !vm.isLoading && vm.hasRestoredOutboxMessages }
}
await outbox.holdLoadAfterFailure()
await transport.goOnline()
await outbox.waitUntilSnapshotCaptured()
do {
#expect(await transport.state.sentMessages.isEmpty)
let failed = try #require(await store.loadCommands().first)
#expect(failed.lastError == (gatewayRejectsSettings
? OpenClawChatSQLiteTranscriptCache.outboxSettingsChangedError
: OpenClawChatSQLiteTranscriptCache.outboxSettingsReviewRequiredError))
if !gatewayRejectsSettings {
#expect(OpenClawChatSQLiteTranscriptCache.outboxDisplayError(failed.lastError) ==
"Session settings were not captured. Review and retry this message.")
}
await transport.state.update { $0.sendSettingsChanged = false }
// A visible failure must authorize retry before any later reload can supply its version.
let messageID = try #require(await MainActor.run {
vm.messages.first { vm.outboxState(for: $0.id)?.isFailed == true }?.id
})
await MainActor.run { vm.retryOutboxMessage(messageID) }
try await waitUntil("reviewed settings rebound before reload") {
await store.loadCommands().first?.status == .queued
}
} catch {
await outbox.releaseSnapshot()
try? await waitUntil("failed proof flush released") {
await MainActor.run { !vm.isFlushingOutbox }
}
throw error
}
await outbox.releaseSnapshot()
try await waitUntil("reviewed settings rebound and sent") {
await transport.state.sentMessages == ["review before replay"]
}
#expect(await transport.state.sentSessionSettings == [
OpenClawChatSessionSettingsExpectation(permissionMode: nil, toolOverrides: nil),
])
}
@Test(arguments: [OpenClawChatOutboxUpdateResult.updated, .unavailable, .confirmed, .superseded])
func `pre CAS parking honors the terminal write result`(
terminalResult: OpenClawChatOutboxUpdateResult) async throws
{
let (store, _, databaseDirectory) = try makeOutboxStore()
defer { try? FileManager.default.removeItem(at: databaseDirectory) }
let command = outboxTestCommand(
id: "pre-cas-settings",
text: "wait for gateway upgrade",
createdAt: Date().timeIntervalSince1970,
expectedSessionSettings: OpenClawChatSessionSettingsExpectation(
permissionMode: .guarded,
toolOverrides: nil))
#expect(await store.enqueueCommand(command))
let outbox = ScriptedOutbox(base: store)
await outbox.setTerminalWriteResult(terminalResult)
let transport = OutboxTestTransport(healthy: false, supportsSessionSettingsCAS: false)
let vm = await makeOutboxViewModel(transport: transport, outbox: outbox)
await MainActor.run { vm.load() }
await transport.goOnline()
try await waitUntil("pre-CAS terminal result settled") {
let commands = await store.loadCommands()
let storedResult = switch terminalResult {
case .updated: commands.first?.status == .failed
case .unavailable: commands.first?.status == .sending
case .confirmed, .missing: commands.isEmpty
case .superseded: commands.first?.status == .sending && commands.first?.attemptVersion == command
.attemptVersion + 1
}
return await MainActor.run {
storedResult && !vm.isFlushingOutbox && (terminalResult != .unavailable || !vm.healthOK)
}
}
#expect(await transport.state.sentMessages.isEmpty)
if terminalResult == .updated {
#expect(await store.loadCommands().first?.lastError ==
OpenClawChatSQLiteTranscriptCache.outboxSettingsGatewayUpgradeRequiredError)
} else {
#expect(await MainActor.run {
vm.messages.allSatisfy { vm.outboxState(for: $0.id)?.isFailed != true }
})
}
}
@Test func `failed restrictive patch cannot release a later automatic flush`() async throws {
let (store, _, databaseDirectory) = try makeOutboxStore()
defer { try? FileManager.default.removeItem(at: databaseDirectory) }
let fullAccess = OpenClawChatSessionSettingsExpectation(permissionMode: .full, toolOverrides: nil)
#expect(await store.enqueueCommand(outboxTestCommand(
id: "failed-restriction",
text: "do not auto release",
createdAt: Date().timeIntervalSince1970,
expectedSessionSettings: fullAccess)))
let patchRelease = DeleteGate()
let patchStarted = DeleteGate()
let catalog = OpenClawChatComposerCapabilityCatalog(
sessionSettingsAvailable: true,
permissionMutationAvailable: true,
sessionSettingsCASAvailable: true)
let transport = OutboxTestTransport(
healthy: false,
sessions: [
outboxSessionEntry(
key: "main",
thinkingLevels: ["off"],
sessionID: "session-main",
permissionMode: .full),
outboxSessionEntry(key: "other", thinkingLevels: ["off"]),
],
supportsSessionSettingsCAS: true,
composerCapabilityCatalog: catalog,
sessionSettingsPatchHook: {
await patchStarted.open()
await patchRelease.wait()
throw NSError(
domain: "ChatViewModelOutboxSettingsTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Restriction was not saved."])
})
let vm = await makeOutboxViewModel(transport: transport, outbox: store)
await MainActor.run { vm.load() }
try await waitUntil("outbox restore") {
await MainActor.run { vm.hasRestoredOutboxMessages }
}
await MainActor.run {
vm.sessions = [outboxSessionEntry(
key: "main",
thinkingLevels: ["off"],
sessionID: "session-main",
permissionMode: .full)]
vm.sessionId = "session-main"
}
await vm.loadComposerCapabilities()
await MainActor.run { vm.selectComposerPermissionMode(.guarded) }
await patchStarted.wait()
#expect(await transport.state.sentMessages.isEmpty)
await MainActor.run { vm.switchSession(to: "other") }
await patchRelease.open()
try await waitUntil("queued row parked before flush") {
await store.loadCommands().first?.status == .failed
}
let reopened = await makeOutboxViewModel(transport: transport, outbox: store)
await MainActor.run {
reopened.load()
reopened.readySessionMetadataGeneration = reopened.sessionMetadataGeneration
reopened.reconciledOutboxBranchScopes.insert(
OpenClawChatOutboxScope(sessionKey: "main", agentID: "main"))
reopened.applyTransportHealth(true)
reopened.flushOutboxIfNeeded()
}
try await Task.sleep(for: .milliseconds(50))
#expect(await transport.state.sentMessages.isEmpty)
#expect(await store.loadCommands().first?.lastError ==
OpenClawChatSQLiteTranscriptCache.outboxSettingsChangedError)
}
@Test func `bulk settings failure immediately publishes failed bubble state`() async throws {
let (store, _, databaseDirectory) = try makeOutboxStore()
defer { try? FileManager.default.removeItem(at: databaseDirectory) }
#expect(await store.enqueueCommand(outboxTestCommand(
id: "visible-settings-failure",
text: "show the failure",
createdAt: Date().timeIntervalSince1970,
expectedSessionSettings: OpenClawChatSessionSettingsExpectation(
permissionMode: .guarded,
toolOverrides: nil))))
let transport = OutboxTestTransport(healthy: false)
let vm = await makeOutboxViewModel(transport: transport, outbox: store)
await MainActor.run { vm.load() }
try await waitUntil("queued bubble restored") {
await MainActor.run { vm.messages.contains { vm.outboxState(for: $0.id) == .queued } }
}
#expect(await store.parkQueuedCommands(
in: OpenClawChatOutboxScope(sessionKey: "main", agentID: "main"),
lastError: "Restriction was not saved."))
try await waitUntil("failed bubble published") {
await MainActor.run {
vm.messages.contains { vm.outboxState(for: $0.id)?.isFailed == true }
}
}
}
@Test func `settings mutation does not reach gateway when durable parking fails`() async throws {
let (store, _, databaseDirectory) = try makeOutboxStore()
defer { try? FileManager.default.removeItem(at: databaseDirectory) }
#expect(await store.enqueueCommand(outboxTestCommand(
id: "parking-unavailable",
text: "keep queued safely",
createdAt: Date().timeIntervalSince1970,
expectedSessionSettings: OpenClawChatSessionSettingsExpectation(
permissionMode: .full,
toolOverrides: nil))))
let scripted = ScriptedOutbox(base: store)
await scripted.setParkingAvailable(false)
let patchCalls = SettingsPatchCounter()
let catalog = OpenClawChatComposerCapabilityCatalog(
sessionSettingsAvailable: true,
permissionMutationAvailable: true,
sessionSettingsCASAvailable: true)
let transport = OutboxTestTransport(
healthy: false,
composerCapabilityCatalog: catalog,
sessionSettingsPatchHook: { await patchCalls.increment() })
let vm = await makeOutboxViewModel(transport: transport, outbox: scripted)
await MainActor.run {
vm.sessions = [outboxSessionEntry(
key: "main",
thinkingLevels: ["off"],
sessionID: "session-main",
permissionMode: .full)]
vm.sessionId = "session-main"
}
await vm.loadComposerCapabilities()
await MainActor.run { vm.selectComposerPermissionMode(.guarded) }
try await waitUntil("parking failure settles") {
await MainActor.run {
!vm.composerCapabilityMutationDisabled &&
vm.errorText == "Could not secure queued messages before changing session settings."
}
}
#expect(await patchCalls.current() == 0)
#expect(await store.loadCommands().first?.status == .queued)
}
}