Fix mobile state and connection races

This commit is contained in:
Kaede Akino committed 2026-09-13 12:18:56 +08:00
1 parent a737283621
commit 8dcc934506
21 files changed
+389 -138

No files matched your search

@@ -35,6 +35,25 @@ struct AssistantConversationOperationsTests {
#expect(restored.canEditUserMessage(restored.messages[0].id))
}
@Test func restoreMergesHistoryCreatedWhileTheRepositoryWasLoading() {
let store = AssistantConversationStore()
let live = ConversationModel(messages: [ChatMessage(role: .user, text: "Created while loading")])
store.insert(live)
let persistedID = UUID()
let persistedMessage = AgentTranscriptMessage(role: .user, text: "Persisted history")
store.restore([
StoredAgentConversation(
id: persistedID,
messages: [persistedMessage],
lastRunState: .completed,
metadata: StoredAgentConversationMetadata()
)
])
#expect(store.conversations.map(\.id) == [persistedID, live.id])
}
@Test func activeRunCannotBeArchivedOrReordered() async throws {
let (repository, conversation, operations) = try await fixture()
conversation.handle(.runState(.running))
@@ -71,8 +71,8 @@ public final class AssistantConversationStore {
}
public func restore(_ stored: [StoredAgentConversation]) {
guard conversations.isEmpty else { return }
conversations = stored.map { value in
let existingIDs = Set(conversations.map(\.id))
let restored = stored.filter { !existingIDs.contains($0.id) }.map { value in
let conversation = ConversationModel(
id: value.id,
messages: value.messages.compactMap { message in
@@ -143,6 +143,7 @@ public final class AssistantConversationStore {
}
return conversation
}
conversations = restored + conversations
}
}
@@ -4,7 +4,7 @@ public nonisolated enum SettingsKey {
public static let appLanguage = "app.language"
public static let defaultICloudSyncEnabled = true
public static let defaultMetricsSyncEnabled = true
public static let defaultSettingsSyncEnabled = true
public static let defaultSettingsSyncEnabled = false
public static let defaultTerminalThemeDark = "Termind Dark"
public static let defaultTerminalThemeLight = "Termind Light"
public static let defaultVaultSyncEnabled = true
+8 -4
View File
@@ -282,8 +282,9 @@ and upgrades do not re-enable it. Pro gates three independent coordinators:
V13 Settings entity names/defaults are frozen; V14 only adds
`PersistentTerminalPreferences`; V15 adds the independent Metrics journal/account/
quarantine. Settings persistence atomically commits data and upload intent. Separate
records cover providers/models, MCP clients, skills, roles, model selection, Web
Search, and terminal preferences/custom text. Remote commits refresh models without
records cover providers/models, MCP clients, skills, roles, Web Search, and terminal
preferences/custom text. The most recently selected conversation model remains
device-local. Remote commits refresh models without
re-entering local mutation. Active Agent runs keep their starting snapshot.
Conversations, approval mode, local MCP listener settings, and unrelated defaults do
@@ -330,8 +331,11 @@ tests/discovery use ephemeral snapshots, cancel on editor/config changes, and ca
repopulate discarded drafts. Secrets remain in memory until atomic save and roll back
on metadata failure. Mobile does not start the local MCP listener.
Each mobile scene owns one navigation/command model and publishes it as a focused
scene value, so hardware-keyboard commands never operate another iPad window. Mobile
Each mobile scene owns one navigation/command model and one Assistant library model,
including its selected conversation and New Chat draft. The app-wide Assistant runtime
and conversation store remain shared so active runs are never duplicated. The scene
model is published as a focused value, so hardware-keyboard commands never operate
another iPad window. Mobile
uses the macOS shortcuts for capabilities that exist on both platforms: navigation,
connection/creation, terminal Find and Command Generator, Workspace close/switch,
and Assistant send/stop. Workspace switching never reconnects the session or claims
+1 -1
View File
@@ -140,7 +140,7 @@ struct MobileFeatureRegistry {
AnyView(
MobileAssistantView(
app: app,
model: app.assistant,
model: scene.assistant,
libraryRoute: Binding(
get: { scene.assistantLibraryRoute },
set: { scene.assistantLibraryRoute = $0 }
+3 -1
View File
@@ -12,6 +12,7 @@ import struct TermindDomain.Host
@Observable
final class MobileSceneModel {
let id = UUID()
let assistant: MobileAssistantSceneModel
var selectedDestination: MobileDestination? = .vault
var workspaceID: MobileWorkspace.ID?
var isConnectionPickerPresented = false
@@ -24,6 +25,7 @@ final class MobileSceneModel {
init(app: MobileAppModel) {
self.app = app
assistant = app.assistant.makeSceneModel()
}
var selectedTab: MobileDestination {
@@ -58,7 +60,7 @@ final class MobileSceneModel {
}
func openNewConversation() {
app.assistant.newConversation()
assistant.newConversation()
assistantLibraryRoute = nil
selectedDestination = .assistant
}
+119 -25
View File
@@ -5,12 +5,12 @@ import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
/// Application-owned runs, with navigation and drafts independent of each run.
/// Application-owned Assistant runs and services. Scene-local navigation lives in
/// `MobileAssistantSceneModel`; only the conversation store is shared here.
@MainActor
@Observable
final class MobileAssistantModel {
let store = AssistantConversationStore()
let library: AssistantLibraryModel
let speech: SpeechTranscriptionCoordinator
let configuration: AIConfigurationModel
let skills: SkillLibraryModel
@@ -21,14 +21,18 @@ final class MobileAssistantModel {
private let connections: AgentConnectionPool
private let background: AgentBackgroundCommands
private let attachmentLoader: ContextAttachmentLoading
private var loadTask: Task<Void, Never>?
private var suspensionTask: Task<Void, Never>?
private var loaded = false
private(set) var isSuspending = false
var error: String?
var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation }
var isRunning: Bool { store.conversations.contains { $0.isStreaming } }
func makeSceneModel() -> MobileAssistantSceneModel {
MobileAssistantSceneModel(runtime: self, library: AssistantLibraryModel(store: store))
}
init(
configuration: AIConfigurationModel, repository: any AgentRunPersisting,
hostServices: AgentHostServices, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel,
@@ -42,7 +46,6 @@ final class MobileAssistantModel {
self.skills = skills
self.speech = speech
self.attachmentLoader = attachmentLoader
library = AssistantLibraryModel(store: store)
operations = AssistantConversationOperations(repository: repository)
let resolver = hostServices.resolver
let connections = hostServices.connections
@@ -60,37 +63,49 @@ final class MobileAssistantModel {
skills: catalog, channel: channel, ai: configuration, hosts: resolver, metrics: metrics,
connections: connections, authentication: authentication, background: background)
}, title: { try await titles.summarize($0) })
configure(conversation)
}
func load() async {
guard !loaded else { return }
loaded = true
if loaded { return }
if let loadTask {
await loadTask.value
return
}
let loadTask = Task { [weak self] in
guard let self else { return }
await loadOnce()
}
self.loadTask = loadTask
await loadTask.value
self.loadTask = nil
}
private func loadOnce() async {
do {
library.restore(try await repository.loadConversations())
store.restore(try await repository.loadConversations())
store.conversations.forEach(configure)
loaded = true
} catch {
loaded = false
self.error = error.localizedDescription
}
}
private func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader }
fileprivate func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader }
func select(_ conversation: ConversationModel) {
fileprivate func select(_ conversation: ConversationModel, in library: AssistantLibraryModel) {
speech.cancelAll()
library.select(conversation.id)
configure(conversation)
}
func newConversation() {
fileprivate func newConversation(in library: AssistantLibraryModel) {
speech.cancelAll()
_ = library.resetDraftConversation()
configure(conversation)
configure(library.draftConversation)
}
func send() {
let conversation = conversation
fileprivate func send(in library: AssistantLibraryModel) {
let conversation = library.selectedConversation ?? library.draftConversation
guard !isSuspending, !conversation.isArchived, !conversation.isHistoryChanging,
!conversation.isCompacting, conversation.composer.pendingAttachments.isEmpty, authorize()
else { return }
@@ -127,7 +142,7 @@ final class MobileAssistantModel {
func waitForSuspension() async { await suspensionTask?.value }
func selectModel(_ value: AIModelRef) {
fileprivate func selectModel(_ value: AIModelRef, for conversation: ConversationModel) {
guard let resolved = configuration.lookup(value) else { return }
conversation.model = value
configuration.select(value)
@@ -136,10 +151,15 @@ final class MobileAssistantModel {
).clamp(conversation.reasoningEffort)
}
func archive(_ conversation: ConversationModel, archived: Bool) async {
fileprivate func archive(
_ conversation: ConversationModel, archived: Bool, in library: AssistantLibraryModel
) async {
await perform {
try await operations.setArchived(archived, conversation: conversation)
if archived && conversation.isArchived && self.conversation.id == conversation.id { newConversation() }
let selected = library.selectedConversation ?? library.draftConversation
if archived && conversation.isArchived && selected.id == conversation.id {
newConversation(in: library)
}
}
}
@@ -147,23 +167,28 @@ final class MobileAssistantModel {
await perform { try await operations.rename(conversation, title: title) }
}
func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async {
fileprivate func move(
_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil,
in library: AssistantLibraryModel
) async {
await perform { try await operations.move(conversation, pinned: pinned, before: before, library: library) }
}
func delete(_ conversation: ConversationModel) async {
fileprivate func delete(_ conversation: ConversationModel, from library: AssistantLibraryModel) async {
guard !conversation.isStreaming, !conversation.isCompacting else { return }
await perform {
let result = try await operations.delete(conversation)
guard result.deleted else { return }
library.delete(conversation.id)
if library.selectedConversation == nil { _ = library.resetDraftConversation() }
configure(self.conversation)
configure(library.selectedConversation ?? library.draftConversation)
if let cleanupError = result.cleanupError { error = cleanupError }
}
}
func fork(_ conversation: ConversationModel, at messageID: UUID) async {
fileprivate func fork(
_ conversation: ConversationModel, at messageID: UUID, in library: AssistantLibraryModel
) async {
await perform {
if let fork = try await operations.fork(
conversation, at: messageID, title: store.nextForkTitle(for: conversation))
@@ -188,16 +213,85 @@ final class MobileAssistantModel {
_ = await execution.compact(conversation, instructions: instructions)
}
func implement(_ card: AgentPlanCard, in conversation: ConversationModel) {
fileprivate func implement(
_ card: AgentPlanCard, in conversation: ConversationModel, library: AssistantLibraryModel
) {
guard !conversation.isArchived, !conversation.isStreaming else { return }
conversation.resolvePlan(card.id, as: .implemented)
conversation.isPlanning = false
conversation.composer.draft = String(localized: "Implement this plan.")
select(conversation)
send()
select(conversation, in: library)
send(in: library)
}
private func perform(_ operation: () async throws -> Void) async {
do { try await operation() } catch { self.error = error.localizedDescription }
}
}
/// Scene-local Assistant navigation over the app-wide conversation store and run services.
///
/// The runtime owns work that must survive navigation. The library owns only the selected
/// conversation and the New Chat draft, so two iPad windows can navigate independently
/// without duplicating a running conversation.
@MainActor
@Observable
final class MobileAssistantSceneModel {
private let runtime: MobileAssistantModel
let library: AssistantLibraryModel
init(runtime: MobileAssistantModel, library: AssistantLibraryModel? = nil) {
self.runtime = runtime
self.library = library ?? AssistantLibraryModel(store: runtime.store)
runtime.configure(self.library.draftConversation)
}
var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation }
var speech: SpeechTranscriptionCoordinator { runtime.speech }
var configuration: AIConfigurationModel { runtime.configuration }
var skills: SkillLibraryModel { runtime.skills }
var isRunning: Bool { runtime.isRunning }
var isSuspending: Bool { runtime.isSuspending }
var error: String? {
get { runtime.error }
set { runtime.error = newValue }
}
func load() async { await runtime.load() }
func select(_ conversation: ConversationModel) { runtime.select(conversation, in: library) }
func newConversation() { runtime.newConversation(in: library) }
func send() { runtime.send(in: library) }
func selectModel(_ value: AIModelRef) { runtime.selectModel(value, for: conversation) }
func archive(_ conversation: ConversationModel, archived: Bool) async {
await runtime.archive(conversation, archived: archived, in: library)
}
func rename(_ conversation: ConversationModel, title: String) async {
await runtime.rename(conversation, title: title)
}
func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async {
await runtime.move(conversation, pinned: pinned, before: before, in: library)
}
func delete(_ conversation: ConversationModel) async {
await runtime.delete(conversation, from: library)
}
func fork(_ conversation: ConversationModel, at messageID: UUID) async {
await runtime.fork(conversation, at: messageID, in: library)
}
func edit(_ conversation: ConversationModel, messageID: UUID, text: String) async {
await runtime.edit(conversation, messageID: messageID, text: text)
}
func compact(_ conversation: ConversationModel, instructions: String = "") async {
await runtime.compact(conversation, instructions: instructions)
}
func implement(_ card: AgentPlanCard, in conversation: ConversationModel) {
runtime.implement(card, in: conversation, library: library)
}
}
@@ -181,41 +181,49 @@ final class MobileSFTPSessionModel {
}
func rename(_ file: RemoteFile, to name: String) async throws {
guard let fileSystem else { return }
guard let fileSystem, let connectionGeneration else { return }
do {
try await fileSystem.rename(file, to: name)
guard self.connectionGeneration == connectionGeneration else { return }
await reload()
} catch {
guard self.connectionGeneration == connectionGeneration else { return }
if handlesConnectionError?(error) != true { throw error }
}
}
func setPermissions(_ file: RemoteFile, mode: UInt32) async throws {
guard let fileSystem else { return }
guard let fileSystem, let connectionGeneration else { return }
do {
try await fileSystem.setPermissions(of: file, to: mode)
guard self.connectionGeneration == connectionGeneration else { return }
await reload()
} catch {
guard self.connectionGeneration == connectionGeneration else { return }
if handlesConnectionError?(error) != true { throw error }
}
}
func createDirectory(_ name: String) async {
guard let fileSystem else { return }
guard let fileSystem, let connectionGeneration else { return }
do {
try await fileSystem.createDirectory(named: name, in: path)
guard self.connectionGeneration == connectionGeneration else { return }
await reload()
} catch {
guard self.connectionGeneration == connectionGeneration else { return }
record(error)
}
}
func delete(_ file: RemoteFile) async {
guard let fileSystem else { return }
guard let fileSystem, let connectionGeneration else { return }
do {
try await fileSystem.remove(file)
guard self.connectionGeneration == connectionGeneration else { return }
await reload()
} catch {
guard self.connectionGeneration == connectionGeneration else { return }
record(error)
}
}
@@ -49,7 +49,7 @@ private struct MobileDismissibleComposerBadge: View {
struct MobileAssistantComposer: View {
let pro: ProAccessController
let model: MobileAssistantModel
let model: MobileAssistantSceneModel
@Bindable var conversation: ConversationModel
let focus: FocusState<Bool>.Binding?
let focusRequest: Int?
@@ -76,7 +76,7 @@ struct MobileAssistantComposer: View {
init(
pro: ProAccessController,
model: MobileAssistantModel,
model: MobileAssistantSceneModel,
conversation: ConversationModel,
focus: FocusState<Bool>.Binding? = nil,
focusRequest: Int? = nil,
@@ -653,8 +653,8 @@ struct MobileAssistantComposer: View {
throw CocoaError(.fileReadCorruptFile)
}
try Task.checkCancellation()
let chip = try await ContextAttachmentImporter.importImage(
data: data, label: String(localized: "Photo"))
let chip = try await conversation.attachmentLoader.image(
data, String(localized: "Photo"))
try Task.checkCancellation()
conversation.finishAttachmentImport(pending.id, with: chip)
} catch is CancellationError { conversation.cancelAttachmentImport(pending.id) } catch {
@@ -459,7 +459,7 @@ private struct MobileAssistantSteeringQueue: View {
struct MobileAssistantConversation: View {
let app: MobileAppModel
let model: MobileAssistantModel
let model: MobileAssistantSceneModel
@Bindable var conversation: ConversationModel
let composerFocus: FocusState<Bool>.Binding
let composerFocusRequest: Int?
@@ -18,7 +18,7 @@ enum MobileAssistantLibraryRoute: String, Identifiable, Hashable {
struct MobileAssistantView: View {
let app: MobileAppModel
@Bindable var model: MobileAssistantModel
@Bindable var model: MobileAssistantSceneModel
@Binding var libraryRoute: MobileAssistantLibraryRoute?
@State private var restoreComposerFocusAfterLibrary = false
@State private var nextComposerFocusRequest = 0
@@ -30,7 +30,7 @@ struct MobileAssistantView: View {
init(
app: MobileAppModel,
model: MobileAssistantModel,
model: MobileAssistantSceneModel,
libraryRoute: Binding<MobileAssistantLibraryRoute?>
) {
self.app = app
@@ -212,7 +212,7 @@ struct MobileAssistantView: View {
}
struct MobileAssistantLibrary: View {
@Bindable var model: MobileAssistantModel
@Bindable var model: MobileAssistantSceneModel
let route: MobileAssistantLibraryRoute
let selected: () -> Void
let showArchived: () -> Void
@@ -221,6 +221,18 @@ struct MobileAssistantLibrary: View {
@State private var title = ""
@State private var deleting: ConversationModel?
init(
model: MobileAssistantSceneModel,
route: MobileAssistantLibraryRoute,
selected: @escaping () -> Void,
showArchived: @escaping () -> Void
) {
self.model = model
self.route = route
self.selected = selected
self.showArchived = showArchived
}
private var archived: Bool { route == .archived }
var body: some View {
+68 -26
View File
@@ -85,9 +85,13 @@ final class MobileWorkspace: Identifiable {
private(set) var uploadNotice: WorkspaceUploadNotice?
private var pendingUpload: MobilePendingUpload?
private var uploadPreparationTask: Task<Void, Never>?
private var uploadPreparationID: UUID?
private var uploadEnqueueTask: Task<Void, Never>?
private var uploadEnqueueID: UUID?
private var uploadNoticeTask: Task<Void, Never>?
private var uploadNoticeID: UUID?
private var remoteTransferPreparationTask: Task<Void, Never>?
private var remoteTransferPreparationID: UUID?
private var pendingDownload: MobilePendingDownload?
private var pendingRemoteCopy: MobilePendingRemoteCopy?
let terminalSettings: TerminalSettingsModel
@@ -967,9 +971,17 @@ final class MobileWorkspace: Identifiable {
cancelPendingUpload()
let connectionGeneration = generation
let preparationID = UUID()
uploadPreparationID = preparationID
uploadNotice = .preparing(destination)
uploadPreparationTask = Task { [weak self] in
guard let self else { return }
defer {
if uploadPreparationID == preparationID {
uploadPreparationTask = nil
uploadPreparationID = nil
}
}
var client: (any SFTPClientProtocol)?
var ownsClient = false
do {
@@ -985,7 +997,8 @@ final class MobileWorkspace: Identifiable {
ownsClient = true
}
try Task.checkCancellation()
guard generation == connectionGeneration, uploadDestination == destination,
guard uploadPreparationID == preparationID,
generation == connectionGeneration, uploadDestination == destination,
let client
else { throw CancellationError() }
let batch = try await SFTPLocalUploadPlanner.prepare(
@@ -994,7 +1007,9 @@ final class MobileWorkspace: Identifiable {
client: client
)
try Task.checkCancellation()
guard generation == connectionGeneration, uploadDestination == destination else {
guard uploadPreparationID == preparationID,
generation == connectionGeneration, uploadDestination == destination
else {
throw CancellationError()
}
guard !batch.isEmpty else {
@@ -1008,7 +1023,6 @@ final class MobileWorkspace: Identifiable {
configuration: configuration,
connectionGeneration: connectionGeneration
)
uploadPreparationTask = nil
if batch.conflictCount > 0 {
uploadNotice = nil
} else {
@@ -1016,13 +1030,12 @@ final class MobileWorkspace: Identifiable {
}
} catch is CancellationError {
if ownsClient { await client?.disconnect() }
uploadNotice = nil
uploadPreparationTask = nil
if uploadPreparationID == preparationID { uploadNotice = nil }
} catch {
if ownsClient { await client?.disconnect() }
guard uploadPreparationID == preparationID else { return }
uploadNotice = nil
operationError = error.localizedDescription
uploadPreparationTask = nil
}
}
return true
@@ -1048,29 +1061,34 @@ final class MobileWorkspace: Identifiable {
}
cancelPendingUpload()
let connectionGeneration = generation
let preparationID = UUID()
remoteTransferPreparationID = preparationID
remoteTransferPreparationTask = Task { [weak self] in
guard let self else { return }
defer {
if remoteTransferPreparationID == preparationID {
remoteTransferPreparationTask = nil
remoteTransferPreparationID = nil
}
}
do {
let batch = try await SFTPRemoteCopyPlanner.prepare(
payload, destination: sftpSession.path,
client: sftp, coordinator: transfers
)
try Task.checkCancellation()
guard generation == connectionGeneration else {
remoteTransferPreparationTask = nil
return
}
guard remoteTransferPreparationID == preparationID,
generation == connectionGeneration
else { return }
pendingRemoteCopy = MobilePendingRemoteCopy(
batch: batch, configuration: configuration,
connectionGeneration: connectionGeneration
)
remoteTransferPreparationTask = nil
if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) }
} catch is CancellationError {
remoteTransferPreparationTask = nil
} catch {
guard remoteTransferPreparationID == preparationID else { return }
operationError = error.localizedDescription
remoteTransferPreparationTask = nil
}
}
}
@@ -1084,28 +1102,33 @@ final class MobileWorkspace: Identifiable {
}
cancelPendingUpload()
let connectionGeneration = generation
let preparationID = UUID()
remoteTransferPreparationID = preparationID
remoteTransferPreparationTask = Task { [weak self] in
guard let self else { return }
defer {
if remoteTransferPreparationID == preparationID {
remoteTransferPreparationTask = nil
remoteTransferPreparationID = nil
}
}
do {
let batch = try await SFTPRemoteDownloadPlanner.prepare(
[file], destination: destination, client: sftp
)
try Task.checkCancellation()
guard generation == connectionGeneration else {
remoteTransferPreparationTask = nil
return
}
guard remoteTransferPreparationID == preparationID,
generation == connectionGeneration
else { return }
pendingDownload = MobilePendingDownload(
batch: batch, configuration: configuration,
connectionGeneration: connectionGeneration
)
remoteTransferPreparationTask = nil
if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) }
} catch is CancellationError {
remoteTransferPreparationTask = nil
} catch {
guard remoteTransferPreparationID == preparationID else { return }
operationError = error.localizedDescription
remoteTransferPreparationTask = nil
}
}
}
@@ -1154,6 +1177,7 @@ final class MobileWorkspace: Identifiable {
}
func cancelPendingRemoteTransfer() {
remoteTransferPreparationID = nil
remoteTransferPreparationTask?.cancel()
remoteTransferPreparationTask = nil
pendingDownload = nil
@@ -1163,9 +1187,18 @@ final class MobileWorkspace: Identifiable {
func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
guard let pendingUpload, let transfers else { return }
self.pendingUpload = nil
uploadEnqueueID = nil
uploadEnqueueTask?.cancel()
let enqueueID = UUID()
uploadEnqueueID = enqueueID
uploadEnqueueTask = Task { [weak self] in
guard let self else { return }
defer {
if uploadEnqueueID == enqueueID {
uploadEnqueueTask = nil
uploadEnqueueID = nil
}
}
do {
let result = try await SFTPLocalUploadPlanner.enqueue(
pendingUpload.batch,
@@ -1185,33 +1218,36 @@ final class MobileWorkspace: Identifiable {
}
}
if pendingUpload.ownsClient { await pendingUpload.client.disconnect() }
guard generation == pendingUpload.connectionGeneration else { return }
guard uploadEnqueueID == enqueueID,
generation == pendingUpload.connectionGeneration
else { return }
if result.changedRemoteDirectories, descriptor.kind == .sftp {
await sftpSession.openDirectory(sftpSession.path)
}
showUploadNotice(
.queued(pendingUpload.batch.rootCount, pendingUpload.destination)
)
uploadEnqueueTask = nil
} catch is CancellationError {
if pendingUpload.ownsClient { await pendingUpload.client.disconnect() }
uploadNotice = nil
uploadEnqueueTask = nil
if uploadEnqueueID == enqueueID { uploadNotice = nil }
} catch {
if pendingUpload.ownsClient { await pendingUpload.client.disconnect() }
guard uploadEnqueueID == enqueueID else { return }
uploadNotice = nil
operationError = error.localizedDescription
uploadEnqueueTask = nil
}
}
}
func cancelPendingUpload() {
cancelPendingRemoteTransfer()
uploadPreparationID = nil
uploadPreparationTask?.cancel()
uploadPreparationTask = nil
uploadEnqueueID = nil
uploadEnqueueTask?.cancel()
uploadEnqueueTask = nil
uploadNoticeID = nil
uploadNoticeTask?.cancel()
uploadNoticeTask = nil
uploadNotice = nil
@@ -1223,13 +1259,19 @@ final class MobileWorkspace: Identifiable {
}
private func showUploadNotice(_ notice: WorkspaceUploadNotice) {
uploadNoticeID = nil
uploadNoticeTask?.cancel()
let noticeID = UUID()
uploadNoticeID = noticeID
uploadNotice = notice
uploadNoticeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled, self?.uploadNotice == notice else { return }
guard !Task.isCancelled, self?.uploadNoticeID == noticeID,
self?.uploadNotice == notice
else { return }
self?.uploadNotice = nil
self?.uploadNoticeTask = nil
self?.uploadNoticeID = nil
}
}
+15 -2
View File
@@ -9,19 +9,32 @@ protocol MobileBackgroundTaskManaging: AnyObject {
@MainActor
final class MobileBackgroundController: MobileBackgroundTaskManaging {
private var task: UIBackgroundTaskIdentifier = .invalid
private var expirationID: UUID?
func begin(expiration: @escaping @MainActor () -> Void) {
finish()
let expirationID = UUID()
self.expirationID = expirationID
task = UIApplication.shared.beginBackgroundTask(withName: "Finish remote session work") { [weak self] in
Task { @MainActor in
guard self?.expirationID == expirationID else { return }
self?.expirationID = nil
expiration()
self?.finish()
self?.endTask()
}
}
if task == .invalid { expiration() }
if task == .invalid, self.expirationID == expirationID {
self.expirationID = nil
expiration()
}
}
func finish() {
expirationID = nil
endTask()
}
private func endTask() {
guard task != .invalid else { return }
UIApplication.shared.endBackgroundTask(task)
task = .invalid
+22
View File
@@ -36,6 +36,28 @@ struct MobileAppTests {
#expect(try app.hosts.snapshot().value.hosts == [host])
}
@Test func assistantNavigationAndDraftsAreSceneLocalWhileConversationsAreShared() throws {
let app = try MobileAppModel(inMemory: true)
let first = MobileSceneModel(app: app)
let second = MobileSceneModel(app: app)
first.assistant.conversation.composer.draft = "first draft"
second.assistant.conversation.composer.draft = "second draft"
let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Shared")])
first.assistant.library.insertAndSelect(conversation)
#expect(second.assistant.library.conversation(id: conversation.id) === conversation)
#expect(second.assistant.library.selectedConversation == nil)
#expect(second.assistant.conversation.composer.draft == "second draft")
second.assistant.select(conversation)
first.assistant.newConversation()
#expect(second.assistant.conversation === conversation)
#expect(first.assistant.conversation !== conversation)
#expect(first.assistant.conversation.composer.draft.isEmpty)
}
@Test func historyModelsUseTheSharedSwiftDataContainer() throws {
let app = try MobileAppModel(inMemory: true)
let hostID = UUID()
+28 -25
View File
@@ -13,6 +13,7 @@ struct MobileAssistantRenderingTests {
@Test func renderUserImageAlignment() async throws {
guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return }
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel()
for index in 0..<7 {
let size =
@@ -45,13 +46,13 @@ struct MobileAssistantRenderingTests {
)
}
conversation.appendUserTurn("Compare these images.")
app.assistant.library.insertAndSelect(conversation)
assistant.library.insertAndSelect(conversation)
try await capture(
"user-images",
language: "en",
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
)
}
@@ -112,8 +113,9 @@ struct MobileAssistantRenderingTests {
}
}
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel.demo(language: try #require(AppLanguage(rawValue: language)))
app.assistant.library.insertAndSelect(conversation)
assistant.library.insertAndSelect(conversation)
conversation.composer.draft =
switch language {
case "zh-Hans": "先检查服务日志。"
@@ -125,26 +127,26 @@ struct MobileAssistantRenderingTests {
try await capture(
"chat", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.draft = "/"
try await capture(
"slash", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.draft = "/skill "
try await capture(
"slash-options", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.draft = ""
conversation.beginCompaction()
try await capture(
"compact-running", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.failCompaction(
text("The context service is unavailable.", "上下文服务不可用。", "上下文服務目前無法使用。")
@@ -152,7 +154,7 @@ struct MobileAssistantRenderingTests {
try await capture(
"compact-failed", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.endCompaction()
conversation.appendUserTurn(
@@ -167,14 +169,14 @@ struct MobileAssistantRenderingTests {
try await capture(
"compact-completed", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.attachmentImportError = text(
"The selected attachment could not be imported.", "无法导入所选附件。", "無法匯入所選附件。")
try await capture(
"attachment-error", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.composer.attachmentImportError = nil
conversation.handle(
@@ -212,12 +214,12 @@ struct MobileAssistantRenderingTests {
try await capture(
"question", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"question-dark", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.preferredColorScheme(.dark))
@@ -228,7 +230,7 @@ struct MobileAssistantRenderingTests {
try await capture(
"question-custom", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
conversation.cancelUserInput()
conversation.present(
@@ -264,12 +266,12 @@ struct MobileAssistantRenderingTests {
try await capture(
"question-multiple", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"question-multiple-large", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, .accessibility2))
@@ -287,12 +289,12 @@ struct MobileAssistantRenderingTests {
try await capture(
"question-secret", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
try await capture(
"question-secret-dark", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}
.environment(\.locale, Locale(identifier: language))
.preferredColorScheme(.dark))
@@ -302,13 +304,13 @@ struct MobileAssistantRenderingTests {
try await capture(
"archived-chat", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
app.assistant.newConversation()
assistant.newConversation()
try await capture(
"empty", language: language,
view: NavigationStack {
MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil))
MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil))
}.environment(\.locale, Locale(identifier: language)))
}
}
@@ -317,6 +319,7 @@ struct MobileAssistantRenderingTests {
guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return }
for language in ["en", "zh-Hans", "zh-Hant"] {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let pinned = ConversationModel.demo(language: try #require(AppLanguage(rawValue: language)))
pinned.isPinned = true
let ordinary = ConversationModel()
@@ -333,14 +336,14 @@ struct MobileAssistantRenderingTests {
case "zh-Hant": "調查資料庫延遲"
default: "Investigate database latency"
}
app.assistant.library.insertAndSelect(pinned)
app.assistant.library.insertAndSelect(ordinary)
app.assistant.library.insertAndSelect(streaming)
assistant.library.insertAndSelect(pinned)
assistant.library.insertAndSelect(ordinary)
assistant.library.insertAndSelect(streaming)
try await capture(
"conversations", language: language,
view: NavigationStack {
MobileAssistantLibrary(
model: app.assistant, route: .conversations,
model: assistant, route: .conversations,
selected: {}, showArchived: {}
).navigationTitle("Conversations")
}.environment(\.locale, Locale(identifier: language)))
@@ -350,7 +353,7 @@ struct MobileAssistantRenderingTests {
"archive", language: language,
view: NavigationStack {
MobileAssistantLibrary(
model: app.assistant, route: .archived,
model: assistant, route: .archived,
selected: {}, showArchived: {}
).navigationTitle("Archived")
}.environment(\.locale, Locale(identifier: language)))
+43 -33
View File
@@ -17,16 +17,18 @@ import UniformTypeIdentifiers
struct MobileAssistantTests {
@Test func shortUserInputUsesItsNaturalHeightAndLongInputUsesTheCap() throws {
let shortApp = try MobileAppModel(inMemory: true)
shortApp.assistant.conversation.present(
let shortAssistant = shortApp.assistant.makeSceneModel()
shortAssistant.conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(id: "answer", header: "Answer", prompt: "Continue?", kind: .text)
]))
let shortHeight = measuredInteractionHeight(
conversation: shortApp.assistant.conversation,
conversation: shortAssistant.conversation,
hosts: shortApp.library
)
let longApp = try MobileAppModel(inMemory: true)
let longAssistant = longApp.assistant.makeSceneModel()
let options = (0..<16).map { index in
AgentUserInputOption(
id: "option-\(index)",
@@ -34,7 +36,7 @@ struct MobileAssistantTests {
detail: "A deliberately detailed explanation that makes this option require another line."
)
}
longApp.assistant.conversation.present(
longAssistant.conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(
id: "choice",
@@ -44,7 +46,7 @@ struct MobileAssistantTests {
)
]))
let longHeight = measuredInteractionHeight(
conversation: longApp.assistant.conversation,
conversation: longAssistant.conversation,
hosts: longApp.library
)
@@ -56,7 +58,8 @@ struct MobileAssistantTests {
@Test func longApprovalUsesTheSameHeightCap() async throws {
let app = try MobileAppModel(inMemory: true)
let conversation = app.assistant.conversation
let assistant = app.assistant.makeSceneModel()
let conversation = assistant.conversation
let invocation = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
@@ -141,7 +144,7 @@ struct MobileAssistantTests {
@Test func changingConversationsPreservesTheirDraftsAndArchiveNavigation() async throws {
let app = try MobileAppModel(inMemory: true)
let model = app.assistant
let model = app.assistant.makeSceneModel()
let first = ConversationModel(messages: [ChatMessage(role: .user, text: "First")])
let second = ConversationModel(messages: [ChatMessage(role: .user, text: "Second")])
model.library.insertAndSelect(first)
@@ -157,24 +160,26 @@ struct MobileAssistantTests {
@Test func explicitNewConversationStartsWithAFreshDraft() throws {
let app = try MobileAppModel(inMemory: true)
app.assistant.conversation.composer.draft = "discard this explicit new-chat draft"
let assistant = app.assistant.makeSceneModel()
assistant.conversation.composer.draft = "discard this explicit new-chat draft"
app.assistant.newConversation()
assistant.newConversation()
#expect(app.assistant.conversation.isEmpty)
#expect(app.assistant.conversation.composer.draft.isEmpty)
#expect(assistant.conversation.isEmpty)
#expect(assistant.conversation.composer.draft.isEmpty)
}
@Test func firstNewConversationSendPromotesDraftAndStartsRun() throws {
let app = try MobileAppModel(inMemory: true)
let draft = app.assistant.conversation
let assistant = app.assistant.makeSceneModel()
let draft = assistant.conversation
draft.composer.draft = "Why is the service unavailable?"
app.assistant.send()
assistant.send()
#expect(app.assistant.library.selectedConversation === draft)
#expect(app.assistant.library.conversations.map(\.id) == [draft.id])
#expect(app.assistant.library.draftConversation !== draft)
#expect(assistant.library.selectedConversation === draft)
#expect(assistant.library.conversations.map(\.id) == [draft.id])
#expect(assistant.library.draftConversation !== draft)
#expect(draft.composer.draft.isEmpty)
#expect(draft.messages.map(\.role) == [.user, .assistant])
#expect(draft.messages.first?.text == "Why is the service unavailable?")
@@ -183,13 +188,14 @@ struct MobileAssistantTests {
@Test func attachmentOnlyFirstSendPromotesTheConversation() throws {
let app = try MobileAppModel(inMemory: true)
let draft = app.assistant.conversation
let assistant = app.assistant.makeSceneModel()
let draft = assistant.conversation
draft.attach(ContextChip(kind: .file, label: "service.log", content: "connection refused"))
app.assistant.send()
assistant.send()
#expect(app.assistant.library.selectedConversation === draft)
#expect(app.assistant.library.conversations.map(\.id) == [draft.id])
#expect(assistant.library.selectedConversation === draft)
#expect(assistant.library.conversations.map(\.id) == [draft.id])
#expect(draft.messages.map(\.role) == [.user, .assistant])
#expect(draft.messages.first?.text.isEmpty == true)
#expect(draft.messages.first?.contextChips.map(\.label) == ["service.log"])
@@ -198,12 +204,13 @@ struct MobileAssistantTests {
@Test func existingConversationSendStillUsesTheDraftExecutionPath() async throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel()
conversation.composer.draft = "Opening question"
_ = app.assistant.library.startConversation(conversation)
_ = assistant.library.startConversation(conversation)
conversation.composer.draft = "Follow-up question"
app.assistant.send()
assistant.send()
#expect(conversation.composer.draft.isEmpty)
#expect(conversation.messages.map(\.text).contains("Follow-up question"))
@@ -217,9 +224,10 @@ struct MobileAssistantTests {
@Test func slashCatalogShowsAlphabetizedCompactPlanAndSkillCommandsOnMobile() throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Inspect")])
app.assistant.library.insertAndSelect(conversation)
let composer = MobileAssistantComposer(pro: app.pro, model: app.assistant, conversation: conversation)
assistant.library.insertAndSelect(conversation)
let composer = MobileAssistantComposer(pro: app.pro, model: assistant, conversation: conversation)
let commands = composer.availableCommands
let first = try #require(commands.first)
#expect(commands.contains { $0.id == "plan" })
@@ -237,6 +245,7 @@ struct MobileAssistantTests {
@Test func selectingAnExplicitModelRemembersItAndClampsEffort() throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
var reasoning = AIModel(id: "reasoning-model")
reasoning.abilities.insert(.reasoning)
reasoning.reasoningProfile = ReasoningProfile(levels: [
@@ -250,21 +259,21 @@ struct MobileAssistantTests {
let provider = ModelProvider(name: "Test", models: [reasoning, plain])
app.ai.upsert(provider)
app.assistant.conversation.reasoningEffort = .low
assistant.conversation.reasoningEffort = .low
let reasoningRef = AIModelRef(modelProviderID: provider.id, modelID: reasoning.id)
app.assistant.selectModel(reasoningRef)
#expect(app.assistant.conversation.model == reasoningRef)
assistant.selectModel(reasoningRef)
#expect(assistant.conversation.model == reasoningRef)
#expect(app.ai.lastSelectedModel == reasoningRef)
#expect(app.assistant.conversation.reasoningEffort == .high)
#expect(assistant.conversation.reasoningEffort == .high)
let plainRef = AIModelRef(modelProviderID: provider.id, modelID: plain.id)
app.assistant.selectModel(plainRef)
#expect(app.assistant.conversation.reasoningEffort == .off)
assistant.selectModel(plainRef)
#expect(assistant.conversation.reasoningEffort == .off)
}
@Test func assistantDropImporterAcceptsFileURLsAndImageData() async throws {
let app = try MobileAppModel(inMemory: true)
let conversation = app.assistant.conversation
let conversation = app.assistant.makeSceneModel().conversation
let directory = FileManager.default.temporaryDirectory.appending(
path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -346,11 +355,12 @@ struct MobileAssistantTests {
@Test func backgroundExpirationBlocksNewRunsUntilCleanupCompletes() async throws {
let app = try MobileAppModel(inMemory: true)
app.assistant.conversation.composer.draft = "preserve"
let assistant = app.assistant.makeSceneModel()
assistant.conversation.composer.draft = "preserve"
app.assistant.expireBackground()
#expect(app.assistant.isSuspending)
app.assistant.send()
#expect(app.assistant.conversation.composer.draft == "preserve")
assistant.send()
#expect(assistant.conversation.composer.draft == "preserve")
await app.assistant.waitForSuspension()
#expect(!app.assistant.isSuspending)
#expect(!app.assistant.isRunning)
+2 -2
View File
@@ -65,11 +65,11 @@ struct MobileShortcutTests {
#expect(scene.editingHost != nil)
scene.assistantLibraryRoute = .archived
let previousConversationID = app.assistant.conversation.id
let previousConversationID = scene.assistant.conversation.id
scene.openNewConversation()
#expect(scene.selectedDestination == .assistant)
#expect(scene.assistantLibraryRoute == nil)
#expect(app.assistant.conversation.id != previousConversationID)
#expect(scene.assistant.conversation.id != previousConversationID)
}
@Test func workspaceCommandsSelectByIndexAndWrapInBothDirections() throws {
+17 -2
View File
@@ -406,8 +406,20 @@ final class SFTPSessionModel: Identifiable {
}
}
)
try Task.checkCancellation()
try await self.resolveInitialPathIfNeeded(using: client)
guard !Task.isCancelled, self.connectionAttemptID == attemptID else {
await client.disconnect()
return
}
do {
try await self.resolveInitialPathIfNeeded(using: client)
} catch {
await client.disconnect()
throw error
}
guard !Task.isCancelled, self.connectionAttemptID == attemptID else {
await client.disconnect()
return
}
self.connectionJourney.receive(.connected(host.id))
self.client = client
self.configuration = self.usesEphemeralCredentials ? nil : configuration
@@ -433,8 +445,10 @@ final class SFTPSessionModel: Identifiable {
TermindSSHError.missingUsername,
TermindSSHError.keyPassphraseRequired
{
guard self.connectionAttemptID == attemptID else { return }
self.requestAuthentication(host: host, store: store)
} catch let error as TermindSSHError where error.shouldRequestAuthentication {
guard self.connectionAttemptID == attemptID else { return }
let authenticationHost: Host
if let id = error.routeHostID, let jump = store.host(id) {
authenticationHost = jump
@@ -456,6 +470,7 @@ final class SFTPSessionModel: Identifiable {
preserveSubmission: retryNeedsPassphrase && submitted != nil
)
} catch {
guard self.connectionAttemptID == attemptID else { return }
if self.connection.hasConnectedOnce || reconnecting {
self.scheduleReconnect(after: error.localizedDescription, using: store)
} else {
+1 -1
View File
@@ -814,7 +814,7 @@ final class SSHSessionModel: Identifiable {
},
shellIntegration: { [weak self] event in
Task { @MainActor in
guard let self else { return }
guard let self, self.connectionAttemptID == attemptID else { return }
self.receiveShellIntegrationEvent(event)
}
}
+6
View File
@@ -230,6 +230,12 @@ struct ArchitectureBoundaryTests {
#expect(!root.contains("switch scene.selectedDestination"))
#expect(features.contains("mutating func register("))
#expect(features.contains("Duplicate mobile feature registration"))
#expect(features.contains("model: scene.assistant"))
#expect(!features.contains("model: app.assistant"))
let scene = try String(
contentsOf: sourceRoot.appending(path: "../TFI/App/MobileSceneModel.swift"), encoding: .utf8)
#expect(scene.contains("let assistant: MobileAssistantSceneModel"))
let descriptor = try String(
contentsOf: sourceRoot.appending(
+1 -1
View File
@@ -301,7 +301,7 @@ struct SettingsSyncTests {
defer { withExtendedLifetime(container) {} }
let before = try store.load().webSearch
let valid = envelope(.webSearch(.init(isEnabled: false, prefersBuiltIn: false)))
var invalid = envelope(.selection(nil))
var invalid = envelope(.terminal(TerminalPreferences()))
invalid.formatVersion = 99
#expect(throws: (any Error).self) { try store.applyRemote([valid, invalid]) }
#expect(try store.load().webSearch == before)