refactor: remove obsolete application paths and adapters

Remove the transfer forwarding adapter, borrowed SFTP channel scheduling,
legacy assistant send entry points, unreachable Telnet password state,
unused Tailscale APIs, and unused host deletion impact projections.
Migrate behavioral tests to current paths and cover SFTP passphrase saving.

Validation: Swift formatting and shared-copy/localization checks passed.
The macOS test run reported failures in ConnectionStatusIconLayoutTests
and AgentRuntimeTests; investigation and iOS validation remain pending.
This commit is contained in:
Kaede Akino committed 2026-10-04 22:10:09 +08:00
1 parent 7c81fc0b50
commit ad2bf28790
29 files changed
+146 -422

No files matched your search

@@ -586,7 +586,10 @@ struct TailscaleIntegrationTests {
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == "exit-one")
#expect(model.exitNodeCandidates(groupID: group.id).map(\.stableID) == ["exit-one"])
#expect(
TailscaleGroupEditor(model: model, group: library.group(group.id)).candidates.map(\.stableID) == [
"exit-one"
])
#expect(!library.hosts.contains { $0.tailscale?.stableNodeID == "exit-one" })
let retained = try model.connection(for: group.id)
group.tailscale?.exitNodeID = "exit-two"
@@ -599,7 +602,9 @@ struct TailscaleIntegrationTests {
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == "exit-two")
try model.saveExitNode("", groupID: group.id)
let editor = TailscaleGroupEditor(model: model, group: library.group(group.id))
editor.exitNodeID = ""
_ = try await editor.save()
for _ in 0..<400 {
if await node.selectedExitNode == nil { break }
try await Task.sleep(for: .milliseconds(10))
@@ -34305,23 +34305,6 @@
}
}
},
"Waiting for a Telnet password" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在等待Telnet密码"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在等待Telnet密碼"
}
}
}
},
"Waiting for an Identity" : {
"extractionState" : "manual",
"localizations" : {
@@ -287,16 +287,6 @@ public final class AssistantLibraryModel {
return draftConversation
}
/// Promotes the New Chat scratch model on its first send, then prepares a
/// fresh scratch model for the next visit to New Chat.
@discardableResult
public func startDraftConversation() -> ConversationModel? {
let conversation = draftConversation
guard let started = startConversation(conversation) else { return nil }
draftConversation = newConversation()
return started
}
public func canPromote(_ conversation: ConversationModel) -> Bool {
!conversations.contains(where: { $0.id == conversation.id })
}
@@ -326,19 +316,6 @@ public final class AssistantLibraryModel {
return conversation
}
/// Promotes the sidebar's scratch conversation into history on its first
/// real send. Empty drafts remain ephemeral and never create blank rows.
@discardableResult
public func startConversation(_ conversation: ConversationModel) -> ConversationModel? {
guard conversation.canSendDraft else { return nil }
guard !conversations.contains(where: { $0.id == conversation.id }) else { return nil }
conversation.sendDraft()
store.insert(conversation)
selectedConversationID = conversation.id
return conversation
}
public func select(_ id: ConversationModel.ID) {
guard conversations.contains(where: { $0.id == id }) else { return }
selectedConversationID = id
@@ -1974,8 +1974,8 @@ public final class ConversationModel: Identifiable {
composer.pendingAttachments.removeAll { $0.id == id }
}
/// Records a local user turn. Agent-backed composers use AppModel's runtime
/// path; the sidebar promotion flow uses this before starting that run.
/// Records a local user turn. Agent-backed composers capture and submit
/// their turns through `AssistantExecutionController`.
public func sendDraft() {
let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard canSendDraft else { return }
@@ -61,7 +61,6 @@ public final class ConnectionJourney {
public enum LocalRequirement: Equatable {
case identity
case tailscaleUsername
case telnetPassword
case passphrase
case proxyAuthentication
}
@@ -98,7 +97,6 @@ public final class ConnectionJourney {
public var waitsForLocalIdentity: Bool { localRequirement == .identity }
public var waitsForLocalPassphrase: Bool { localRequirement == .passphrase }
public var waitsForProxyAuthentication: Bool { localRequirement == .proxyAuthentication }
public var waitsForTelnetPassword: Bool { localRequirement == .telnetPassword }
private static var localDeviceName: String {
#if os(macOS)
@@ -354,20 +352,6 @@ public final class ConnectionJourney {
)
}
public func waitForTelnetPassword(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
localRequirement = .telnetPassword
localRequirementEndpointIndex = 0
phase = .waiting
log(
endpoint: endpoints[index].name,
message: failure ?? String(localized: "Waiting for a Telnet password")
)
}
public func waitForHostKey(at hostID: Host.ID, fingerprint: String) {
wait(
at: hostID, kind: .hostKey,
@@ -149,22 +149,6 @@ public final class TailscaleModel: TailscaleConnectionProviding {
public func exitNodeID(groupID: UUID) -> String { library.group(groupID)?.tailscale?.exitNodeID ?? "" }
public func exitNodeCandidates(groupID: UUID) -> [TailscalePeer] {
(peers[groupID] ?? []).filter { $0.exitNodeOption == true }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
public func saveExitNode(_ stableID: String, groupID: UUID) throws {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard var group = library.group(groupID), group.tailscale != nil else {
throw TailscaleConfigurationError.unavailable
}
guard exitNodeID(groupID: groupID) != stableID else { return }
group.tailscale?.exitNodeID = stableID.isEmpty ? nil : stableID
library.upsert(group)
if let error = library.storageError { throw TermindSSHError.library(error) }
}
public func saveHostname(_ value: String, groupID: UUID) async throws {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard var state = states[groupID] else { throw TailscaleConfigurationError.unavailable }
@@ -55,7 +55,6 @@ public protocol HostRepository: AnyObject {
func changes() -> AsyncStream<RepositoryChange<HostLibrarySnapshot>>
func refresh() throws
func upsertHost(_ host: Host) throws
func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact
func requestHostDeletion(_ hostID: Host.ID) throws
func clearReferencesForDeletedHost(_ hostID: Host.ID) throws
func finalizeHostDeletion(_ hostID: Host.ID) throws
@@ -1,17 +1,5 @@
import Foundation
public nonisolated struct HostDeletionImpact: Equatable, Sendable {
public var jumpHostReferences: Int
public var snippetReferences: Int
public var forwardingRules: Int
public init(jumpHostReferences: Int, snippetReferences: Int, forwardingRules: Int) {
self.jumpHostReferences = jumpHostReferences
self.snippetReferences = snippetReferences
self.forwardingRules = forwardingRules
}
}
public nonisolated struct HostDeletionPayload: Codable, Hashable, Sendable {
public var hostID: Host.ID
public var forwardingRuleIDs: [PortForwardingRule.ID]
@@ -50,13 +50,6 @@ public final class InMemoryHostRepository: HostRepository {
try publish(.mutation)
}
public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact {
HostDeletionImpact(
jumpHostReferences: value.hosts.filter { $0.jumpHostID == hostID }.count,
snippetReferences: 0, forwardingRules: 0
)
}
public func requestHostDeletion(_ hostID: Host.ID) throws {
value.hosts.removeAll { $0.id == hostID }
try publish(.mutation)
@@ -63,17 +63,6 @@ public final class SwiftDataHostRepository: HostRepository {
try commitMutation()
}
public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact {
let hosts = try context.fetch(FetchDescriptor<PersistentHost>())
let snippets = try context.fetch(FetchDescriptor<PersistentSnippet>())
let rules = try forwardingRules(for: hostID)
return HostDeletionImpact(
jumpHostReferences: hosts.filter { $0.jumpHostID == hostID }.count,
snippetReferences: snippets.filter { $0.targetHostIDs.contains(hostID) }.count,
forwardingRules: rules.count
)
}
/// The deletion marker, all durable side effects, and the revision become
/// visible in one database commit. A crash can therefore see either the
/// live host or a complete deletion saga, never half of each.
@@ -219,7 +219,6 @@ private struct JourneyEndpointView: View {
if let localRequirement {
switch localRequirement {
case .identity, .tailscaleUsername: return "person.fill"
case .telnetPassword: return "key.fill"
case .passphrase: return "key.fill"
case .proxyAuthentication: return "lock.shield.fill"
}
@@ -38,15 +38,9 @@ public actor SFTPConcurrencyScheduler {
var hostIDs: Set<UUID>
var source: SFTPWorkSource
var byteCount: Int64?
var exclusiveResourceIDs: Set<UUID>
var continuation: CheckedContinuation<SFTPConcurrencyPermit, any Error>
}
private struct ActiveRequest {
var hostIDs: Set<UUID>
var exclusiveResourceIDs: Set<UUID>
}
private var preferences: SFTPPreferences
private var requests: [UUID: Request] = [:]
private var queues: [UUID: [SFTPWorkSource: [UUID]]] = [:]
@@ -54,9 +48,8 @@ public actor SFTPConcurrencyScheduler {
private var sourceOrder: [UUID: [SFTPWorkSource]] = [:]
private var lastGrantedHost: UUID?
private var lastGrantedSource: [UUID: SFTPWorkSource] = [:]
private var active: [UUID: ActiveRequest] = [:]
private var active: [UUID: Set<UUID>] = [:]
private var activePerHost: [UUID: Int] = [:]
private var activeExclusiveResources: Set<UUID> = []
public init(preferences: SFTPPreferences = SFTPPreferences()) {
self.preferences = preferences.normalized
@@ -71,8 +64,7 @@ public actor SFTPConcurrencyScheduler {
anchorHostID: UUID,
hostIDs: Set<UUID>,
source: SFTPWorkSource,
byteCount: Int64?,
exclusiveResourceIDs: Set<UUID> = []
byteCount: Int64?
) async throws -> SFTPConcurrencyPermit {
try Task.checkCancellation()
let id = UUID()
@@ -85,7 +77,6 @@ public actor SFTPConcurrencyScheduler {
hostIDs: normalizedHosts,
source: source,
byteCount: byteCount,
exclusiveResourceIDs: exclusiveResourceIDs,
continuation: continuation
)
requests[id] = request
@@ -121,12 +112,11 @@ public actor SFTPConcurrencyScheduler {
}
private func release(_ id: UUID) {
guard let request = active.removeValue(forKey: id) else { return }
for hostID in request.hostIDs {
guard let hostIDs = active.removeValue(forKey: id) else { return }
for hostID in hostIDs {
let remaining = max(0, activePerHost[hostID, default: 1] - 1)
activePerHost[hostID] = remaining == 0 ? nil : remaining
}
activeExclusiveResources.subtract(request.exclusiveResourceIDs)
drain()
}
@@ -135,11 +125,8 @@ public actor SFTPConcurrencyScheduler {
requests[request.id] = nil
removeQueuedID(request.id, request: request)
let permitID = UUID()
active[permitID] = ActiveRequest(
hostIDs: request.hostIDs,
exclusiveResourceIDs: request.exclusiveResourceIDs)
active[permitID] = request.hostIDs
for hostID in request.hostIDs { activePerHost[hostID, default: 0] += 1 }
activeExclusiveResources.formUnion(request.exclusiveResourceIDs)
lastGrantedHost = request.anchorHostID
lastGrantedSource[request.anchorHostID] = request.source
let mode = schedulingMode(byteCount: request.byteCount)
@@ -159,7 +146,6 @@ public actor SFTPConcurrencyScheduler {
guard let id = hostQueues[source]?.first, let request = requests[id] else { continue }
guard request.hostIDs.allSatisfy({ activePerHost[$0, default: 0] < preferences.perHostLimit })
else { continue }
guard request.exclusiveResourceIDs.isDisjoint(with: activeExclusiveResources) else { continue }
return request
}
}
@@ -202,7 +188,7 @@ public actor SFTPConcurrencyScheduler {
queues[hostID] = hostQueues
}
}
let activeHosts = Set(active.values.flatMap(\.hostIDs))
let activeHosts = Set(active.values.flatMap { $0 })
for hostID in Array(lastGrantedSource.keys)
where queues[hostID] == nil && !activeHosts.contains(hostID) {
lastGrantedSource[hostID] = nil
@@ -12,15 +12,10 @@ public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable {
public nonisolated enum SFTPTransferConnection: Sendable {
case saved(SSHAdditionalConnectionSnapshot)
case context(SFTPTransferContext)
case borrowed(id: UUID, channel: any SFTPChannelProtocol)
public var managedContext: SFTPTransferContext? {
if case .context(let context) = self { context } else { nil }
}
public var borrowedID: UUID? {
if case .borrowed(let id, _) = self { id } else { nil }
}
}
public nonisolated struct SFTPRemoteCopySource: Sendable {
@@ -116,7 +111,6 @@ public final class SFTPTransferCoordinator {
private var preferencesUpdateTask: Task<Void, Never>?
private var activeTasks: [SFTPTransfer.ID: Task<Void, Never>] = [:]
private var activePermits: [SFTPTransfer.ID: SFTPConcurrencyPermit] = [:]
private var activeBorrowedConnections: Set<UUID> = []
private var remoteCopySources: [UUID: (source: SFTPRemoteCopySource, createdAt: Date)] = [:]
public private(set) var globalLimit: Int
@@ -214,13 +208,6 @@ public final class SFTPTransferCoordinator {
}
let reservation = UUID()
retain(source.connection, for: reservation)
let borrowedID = source.connection.borrowedID
if let borrowedID {
try await reserveBorrowedConnection(borrowedID)
}
defer {
if let borrowedID { releaseBorrowedConnections([borrowedID]) }
}
var lease: ChannelLease?
do {
let acquired = try await clientLease(for: source.connection)
@@ -343,12 +330,6 @@ public final class SFTPTransferCoordinator {
)
}
public func replaceBorrowedChannel(_ connectionID: UUID, with channel: any SFTPChannelProtocol) {
for id in Array(runtimes.keys) where runtimes[id]?.connection.borrowedID == connectionID {
runtimes[id]?.connection = .borrowed(id: connectionID, channel: channel)
}
}
public func canPause(_ id: SFTPTransfer.ID) -> Bool {
guard let transfer = store.sftpTransfer(id) else { return false }
return runtimes[id] != nil && transfer.state.isActive
@@ -430,8 +411,6 @@ public final class SFTPTransferCoordinator {
try await runtime.resume.cleanup(using: channel)
} else {
do {
if let id = runtime.connection.borrowedID { try await reserveBorrowedConnection(id) }
defer { if let id = runtime.connection.borrowedID { releaseBorrowedConnections([id]) } }
let lease = try await clientLease(for: runtime.connection)
do { try await runtime.resume.cleanup(using: lease.channel) } catch {
await lease.disconnect()
@@ -466,21 +445,6 @@ public final class SFTPTransferCoordinator {
}
}
public func cancelTransfers(usingBorrowedConnection connectionID: UUID) async {
let ids = store.sftpTransfers.compactMap { transfer -> SFTPTransfer.ID? in
guard !transfer.state.isFinished,
borrowedIDs(for: transfer).contains(connectionID)
else { return nil }
return transfer.id
}
for id in ids { cancel(id) }
let tasks = ids.compactMap { activeTasks[$0] }
for task in tasks { await task.value }
while activeBorrowedConnections.contains(connectionID) {
try? await Task.sleep(for: .milliseconds(10))
}
}
public func clearFinished() { store.clearFinishedSFTPTransfers() }
public func removeFinished(_ id: SFTPTransfer.ID) { store.removeFinishedSFTPTransfer(id) }
@@ -494,7 +458,6 @@ public final class SFTPTransferCoordinator {
let hostIDs = Set(hostIDs(for: sftpTransfer))
guard let anchorHostID = anchorHostID(for: sftpTransfer), !hostIDs.isEmpty else { continue }
let source = workSource(for: sftpTransfer)
let exclusiveResourceIDs = Set(borrowedIDs(for: sftpTransfer))
let scheduler = concurrencyScheduler
let preferencesUpdateTask = preferencesUpdateTask
admissionTasks[sftpTransfer.id] = Task { @MainActor [weak self] in
@@ -504,8 +467,7 @@ public final class SFTPTransferCoordinator {
anchorHostID: anchorHostID,
hostIDs: hostIDs,
source: source,
byteCount: sftpTransfer.byteCount >= 0 ? sftpTransfer.byteCount : nil,
exclusiveResourceIDs: exclusiveResourceIDs
byteCount: sftpTransfer.byteCount >= 0 ? sftpTransfer.byteCount : nil
)
guard let self else {
await permit.release()
@@ -534,10 +496,8 @@ public final class SFTPTransferCoordinator {
startRemoteCopy(sftpTransfer, runtime: runtime, mode: mode)
return
}
guard let hostID = sftpTransfer.hostID, let runtime = runtimes[sftpTransfer.id] else { return }
guard let runtime = runtimes[sftpTransfer.id] else { return }
store.setSFTPTransferState(.running, for: sftpTransfer.id)
let borrowedIDs = Array(runtime.connection.borrowedID.map { [$0] } ?? [])
activeBorrowedConnections.formUnion(borrowedIDs)
let task = Task { [weak self] in
guard let self else { return }
let scoped = runtime.scopedRoot.startAccessingSecurityScopedResource()
@@ -606,10 +566,7 @@ public final class SFTPTransferCoordinator {
totalBytes: snapshot.source?.byteCount ?? sftpTransfer.byteCount, bytesPerSecond: 0)
}
await lease?.disconnect()
await finish(
sftpTransfer.id, hostIDs: [hostID], borrowedIDs: borrowedIDs,
state: result
)
await finish(sftpTransfer.id, state: result)
}
activeTasks[sftpTransfer.id] = task
}
@@ -626,13 +583,7 @@ public final class SFTPTransferCoordinator {
runtime: RemoteCopyRuntime,
mode: SSHChannelSchedulingMode
) {
let involvedHostIDs = Array(Set([runtime.sourceHostID, runtime.destinationHostID]))
store.setSFTPTransferState(.running, for: sftpTransfer.id)
let borrowedIDs = [
runtime.sourceConnection.borrowedID,
runtime.destinationConnection.borrowedID,
].compactMap { $0 }
activeBorrowedConnections.formUnion(borrowedIDs)
let task = Task { [weak self] in
guard let self else { return }
var sourceLease: ChannelLease?
@@ -687,10 +638,7 @@ public final class SFTPTransferCoordinator {
}
await sourceLease?.disconnect()
await destinationLease?.disconnect()
await finish(
sftpTransfer.id, hostIDs: involvedHostIDs,
borrowedIDs: borrowedIDs, state: result
)
await finish(sftpTransfer.id, state: result)
}
activeTasks[sftpTransfer.id] = task
}
@@ -748,8 +696,6 @@ public final class SFTPTransferCoordinator {
throw error
}
return ChannelLease(channel: client) { await client.disconnect() }
case .borrowed(_, let channel):
return ChannelLease(channel: channel) {}
}
}
@@ -771,32 +717,8 @@ public final class SFTPTransferCoordinator {
.sorted { $0.id.uuidString < $1.id.uuidString }
}
private func borrowedIDs(for sftpTransfer: SFTPTransfer) -> [UUID] {
if let runtime = remoteCopyRuntimes[sftpTransfer.id] {
return [
runtime.sourceConnection.borrowedID,
runtime.destinationConnection.borrowedID,
].compactMap { $0 }
}
return runtimes[sftpTransfer.id]?.connection.borrowedID.map { [$0] } ?? []
}
private func reserveBorrowedConnection(_ id: UUID) async throws {
while activeBorrowedConnections.contains(id) {
try Task.checkCancellation()
try await Task.sleep(for: .milliseconds(10))
}
activeBorrowedConnections.insert(id)
}
private func releaseBorrowedConnections(_ ids: [UUID]) {
activeBorrowedConnections.subtract(ids)
schedule()
}
private func finish(
_ id: SFTPTransfer.ID, hostIDs: [Host.ID], borrowedIDs: [UUID] = [],
state: SFTPTransfer.State
_ id: SFTPTransfer.ID, state: SFTPTransfer.State
) async {
for context in managedContexts(for: id) {
context.releaseSlot()
@@ -813,7 +735,6 @@ public final class SFTPTransferCoordinator {
}
activeTasks.removeValue(forKey: id)
let permit = activePermits.removeValue(forKey: id)
releaseBorrowedConnections(borrowedIDs)
await permit?.release()
schedule()
}
+2 -3
View File
@@ -266,7 +266,6 @@ struct MobileAssistantTests {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel()
_ = assistant.library.startConversation(conversation)
let plan = AgentPlan(
title: "Implementation plan", body: "Apply the requested change.",
scratchPath: "/tmp/plan.md")
@@ -295,7 +294,8 @@ struct MobileAssistantTests {
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel()
conversation.composer.draft = "Opening question"
_ = assistant.library.startConversation(conversation)
conversation.sendDraft()
assistant.library.insertAndSelect(conversation)
conversation.composer.draft = "Follow-up question"
assistant.send()
@@ -537,7 +537,6 @@ struct MobileAssistantTests {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = ConversationModel()
_ = assistant.library.startConversation(conversation)
let plan = AgentPlan(
title: "Implementation plan", body: "Apply the requested change.",
scratchPath: "/tmp/plan.md")
+14 -7
View File
@@ -23,7 +23,8 @@ struct MobileWorkspaceSessionTests {
other.agentForwarding.isActive = true
let conversation = ConversationModel()
conversation.composer.draft = "Agent forwarding"
_ = try #require(owner.assistant.library.startConversation(conversation))
conversation.sendDraft()
owner.assistant.library.insertAndSelect(conversation)
owner.openAssistantNotification(conversation.id)
let hostID = UUID()
let request = SSHConnectionRequest(
@@ -59,7 +60,8 @@ struct MobileWorkspaceSessionTests {
].sorted { $0.id.uuidString < $1.id.uuidString }
let conversation = ConversationModel()
conversation.composer.draft = "Target"
_ = try #require(scenes[0].assistant.library.startConversation(conversation))
conversation.sendDraft()
scenes[0].assistant.library.insertAndSelect(conversation)
for scene in scenes {
scene.openAssistantNotification(conversation.id)
if !visible { scene.selectedDestination = .vault }
@@ -85,7 +87,8 @@ struct MobileWorkspaceSessionTests {
let other = MobileSceneModel(app: app, sessionID: "active-other")
let conversation = ConversationModel()
conversation.composer.draft = "Target"
_ = try #require(owner.assistant.library.startConversation(conversation))
conversation.sendDraft()
owner.assistant.library.insertAndSelect(conversation)
owner.openAssistantNotification(conversation.id)
if libraryPresented {
owner.assistantLibraryRoute = .conversations
@@ -155,8 +158,10 @@ struct MobileWorkspaceSessionTests {
let second = ConversationModel()
first.composer.draft = "First"
second.composer.draft = "Second"
_ = try #require(scene.assistant.library.startConversation(first))
_ = try #require(scene.assistant.library.startConversation(second))
first.sendDraft()
scene.assistant.library.insertAndSelect(first)
second.sendDraft()
scene.assistant.library.insertAndSelect(second)
app.sceneChanged(scene.id, phase: .active)
var activations: [String] = []
scene.assistant.library.select(first.id)
@@ -258,7 +263,8 @@ struct MobileWorkspaceSessionTests {
let second = MobileSceneModel(app: app, sessionID: "assistant-b")
let conversation = ConversationModel()
conversation.composer.draft = "Test"
_ = try #require(first.assistant.library.startConversation(conversation))
conversation.sendDraft()
first.assistant.library.insertAndSelect(conversation)
first.openAssistantNotification(conversation.id)
app.sceneChanged(first.id, phase: .active)
#expect(app.isAssistantNotificationFocused(conversation.id))
@@ -268,7 +274,8 @@ struct MobileWorkspaceSessionTests {
app.sceneChanged(second.id, phase: .active)
let peer = ConversationModel()
peer.composer.draft = "Second conversation"
_ = try #require(second.assistant.library.startConversation(peer))
peer.sendDraft()
second.assistant.library.insertAndSelect(peer)
second.openAssistantNotification(peer.id)
#expect(app.isAssistantNotificationFocused(conversation.id))
#expect(app.isAssistantNotificationFocused(peer.id))
-2
View File
@@ -60,7 +60,6 @@ final class AppModel {
let credentials: CredentialLibraryModel
let snippets: SnippetLibraryModel
let forwardings: ForwardingLibraryModel
let transfers: TransferLibraryModel
let connectionHistory: ConnectionHistoryModel
let devices: DeviceDirectoryModel
private let externalToolCalls: ToolAuditModel
@@ -238,7 +237,6 @@ final class AppModel {
credentials = CredentialLibraryModel(library: hosts)
snippets = SnippetLibraryModel(library: hosts)
forwardings = ForwardingLibraryModel(library: hosts)
transfers = TransferLibraryModel(library: hosts)
self.connectionHistory = connectionHistory
let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
self.devices = devices
@@ -1,137 +0,0 @@
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
/// Saved-vault access exposed to transfer presentation and session entry points.
@Observable
final class TransferLibraryModel {
private let library: HostLibraryModel
init(library: HostLibraryModel) {
self.library = library
}
}
extension TransferLibraryModel: RemoteSessionHostStore {
func host(_ id: Host.ID) -> Host? { library.host(id) }
func networkConfigurationStamp(for host: Host) -> Data { library.networkConfigurationStamp(for: host) }
func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
try library.tailscaleNetwork(for: host)
}
func savedTailscaleUsername(for host: Host) -> String? { library.savedTailscaleUsername(for: host) }
func tailscaleUsername(for host: Host) -> String { library.tailscaleUsername(for: host) }
@discardableResult func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool {
library.saveTailscaleUsername(username, hostID: hostID)
}
func identity(_ id: Identity.ID) -> Identity? { library.identity(id) }
func identity(for host: Host) -> Identity? { library.identity(for: host) }
func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? {
library.customIdentity(for: hostID, purpose: purpose)
}
var customPasswordStorage: PasswordStorage { library.customPasswordStorage }
func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) }
func knownHosts(for host: Host) -> [KnownHostEntry] { library.knownHosts(for: host) }
func route(for host: Host) -> [Host] { library.route(for: host) }
func expectedHostKeys(
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> Set<SSHHostKeyIdentity> {
try library.expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try library.prepareSSHConnection(
for: host, submissions: submissions, proxySubmissions: proxySubmissions)
}
func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> SSHConnectionConfiguration {
try library.sshConnectionConfiguration(
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try library.sshJumpConfigurations(
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try library.prepareSSHConnection(
for: route, submissions: submissions, proxySubmissions: proxySubmissions)
}
func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try library.sshJumpConfigurations(
for: route, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy
)
}
func saveConnectionRoute(
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission?,
authenticationHostID: Host.ID?, suppliedPassword: String?
) throws -> SSHConnectionRoute {
try library.saveConnectionRoute(
route, authentication: authentication, authenticationHostID: authenticationHostID,
suppliedPassword: suppliedPassword
)
}
func transportProxy(for host: Host) throws -> SSHTransportProxy? {
try library.transportProxy(for: host)
}
func transportProxy(
for host: Host, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy? {
try library.transportProxy(for: host, submission: submission)
}
func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
library.sshCredentials(for: identity)
}
func passwordSecret(for identity: Identity) throws -> SSHSecret? {
try library.passwordSecret(for: identity)
}
func passwordAvailability(for identity: Identity) -> SecretAvailability {
library.passwordAvailability(for: identity)
}
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try library.keySecretCredential(for: key)
}
func setConnectionStatus(
_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID
) {
library.setConnectionStatus(status, connectionID: connectionID, hostID: hostID)
}
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) {
library.recordRemoteSystemInfo(systemInfo, for: hostID)
}
func trust(_ entry: KnownHostEntry) { library.trust(entry) }
@discardableResult func upsert(_ identity: Identity) -> Bool { library.upsert(identity) }
func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws {
try library.upsert(identity, passwordUpdate: passwordUpdate)
}
func upsert(_ host: Host) { library.upsert(host) }
}
+5 -5
View File
@@ -309,17 +309,17 @@ struct SFTPBrowserView: View {
identityID, username, password, passphrase, keyID, save in
sftpChannel.continueAuthentication(
identityID: identityID, username: username, password: password,
passphrase: passphrase, keyID: keyID, save: save, using: app.transfers
passphrase: passphrase, keyID: keyID, save: save, using: app.hostLibrary
)
},
continueHostKey: {
sftpChannel.continueWithTemporaryHostKey($0)
},
trustHostKey: { sftpChannel.trustAndConnect($0, using: app.transfers) },
trustHostKey: { sftpChannel.trustAndConnect($0, using: app.hostLibrary) },
denyHostKey: {
cancelConnection()
},
retry: { sftpChannel.connect(using: app.transfers) },
retry: { sftpChannel.connect(using: app.hostLibrary) },
cancel: cancelConnection,
reconnectState: sftpChannel.reconnectState,
retryReconnectNow: sftpChannel.retryReconnectNow,
@@ -337,7 +337,7 @@ struct SFTPBrowserView: View {
.background(TermindColor.windowSurface)
.onAppear {
sftpChannel.bindTransfers(to: app.sftpTransfers)
sftpChannel.connectIfNeeded(using: app.transfers)
sftpChannel.connectIfNeeded(using: app.hostLibrary)
sftpChannel.startAutomaticRefresh()
}
.alert(AppStrings.SFTP.newFolder, isPresented: $newFolderPresented) {
@@ -630,7 +630,7 @@ struct SFTPBrowserView: View {
Image(systemName: "folder.badge.questionmark")
.font(.largeTitle).foregroundStyle(.tertiary)
Text(AppStrings.Connections.notConnected).foregroundStyle(.secondary)
Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.transfers) }
Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.hostLibrary) }
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -764,15 +764,6 @@ final class WindowModel {
assistantCommands?.edit(id, messageID, text, context, terminalSuggestionsAvailable)
}
@discardableResult
func sendAssistantSidebarDraft() -> ConversationModel? {
let draft = sidebar.assistantDraft
guard let conversation = assistantLibrary.startConversation(draft) else { return nil }
isAssistantArchivePresented = false
sidebar.assistantDraft = ConversationModel()
return conversation
}
func assistantSidebarConversationDidStart(_ conversation: ConversationModel) {
isAssistantArchivePresented = false
assistantLibrary.select(conversation.id)
@@ -17,7 +17,8 @@ struct CommandNotificationRenderingTests {
app.registerWindow(windowModel)
let conversation = ConversationModel()
conversation.composer.draft = "Deploy staging"
_ = try #require(windowModel.assistantLibrary.startConversation(conversation))
conversation.sendDraft()
windowModel.assistantLibrary.insertAndSelect(conversation)
let coordinator = AssistantNotificationCoordinator(
service: app.notifications, preferences: { .init() }, isFocused: { _ in false },
content: AssistantNotificationContent.make)
+16 -8
View File
@@ -16,7 +16,8 @@ struct CommandNotificationTests {
app.registerWindow(window)
let conversation = ConversationModel()
conversation.composer.draft = "Target"
_ = try #require(window.assistantLibrary.startConversation(conversation))
conversation.sendDraft()
window.assistantLibrary.insertAndSelect(conversation)
let workspace = Workspace(pane: Pane(.empty))
workspace.splitFocusedPane(axis: .horizontal, with: Pane(.empty))
window.open(workspace)
@@ -30,7 +31,8 @@ struct CommandNotificationTests {
case "peer":
let peer = ConversationModel()
peer.composer.draft = "Other conversation"
_ = try #require(window.assistantLibrary.startConversation(peer))
peer.sendDraft()
window.assistantLibrary.insertAndSelect(peer)
case "archive": window.showAssistantArchiveInSidebar()
default: break
}
@@ -55,7 +57,8 @@ struct CommandNotificationTests {
for window in windows { app.registerWindow(window) }
let conversation = ConversationModel()
conversation.composer.draft = "Target"
_ = try #require(windows[0].assistantLibrary.startConversation(conversation))
conversation.sendDraft()
windows[0].assistantLibrary.insertAndSelect(conversation)
for window in windows {
window.openAssistantFullScreen(conversation.id)
if !visible { window.selectVault() }
@@ -79,7 +82,8 @@ struct CommandNotificationTests {
app.registerWindow(other)
let conversation = ConversationModel()
conversation.composer.draft = "Target"
_ = try #require(owner.assistantLibrary.startConversation(conversation))
conversation.sendDraft()
owner.assistantLibrary.insertAndSelect(conversation)
owner.openAssistantFullScreen(conversation.id)
let workspace = Workspace(pane: Pane(.empty))
owner.open(workspace)
@@ -105,8 +109,10 @@ struct CommandNotificationTests {
let second = ConversationModel()
first.composer.draft = "First"
second.composer.draft = "Second"
_ = try #require(window.assistantLibrary.startConversation(first))
_ = try #require(window.assistantLibrary.startConversation(second))
first.sendDraft()
window.assistantLibrary.insertAndSelect(first)
second.sendDraft()
window.assistantLibrary.insertAndSelect(second)
let activations = AsyncStream<Void>.makeStream()
var count = 0
let router = DesktopNotificationRouter(app: app) { _ in
@@ -213,7 +219,8 @@ struct CommandNotificationTests {
app.registerWindow(other)
let conversation = ConversationModel()
conversation.composer.draft = "Test"
_ = try #require(window.assistantLibrary.startConversation(conversation))
conversation.sendDraft()
window.assistantLibrary.insertAndSelect(conversation)
window.openAssistantFullScreen(conversation.id)
#expect(app.isAssistantNotificationFocused(conversation.id))
#expect(app.isAssistantNotificationFocused(UUID()))
@@ -229,7 +236,8 @@ struct CommandNotificationTests {
#expect(other.assistantLibrary.selectedConversation === conversation)
let peer = ConversationModel()
peer.composer.draft = "Second conversation"
_ = try #require(window.assistantLibrary.startConversation(peer))
peer.sendDraft()
window.assistantLibrary.insertAndSelect(peer)
window.openAssistantInSidebar(peer.id)
window.showsSidebar = true
other.open(Workspace(pane: Pane(.empty)))
@@ -100,6 +100,38 @@ struct DesktopAuthenticationSubmissionTests {
}
}
@Test(arguments: [false, true])
func sftpPassphraseContinuationSavesOnlyWhenRequested(_ save: Bool) throws {
let key = protectedKey()
let secrets = InMemorySecretStore()
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Server", host: "server.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
let app = AppModel(hosts: library)
let channel = SFTPChannelModel(hostID: host.id)
defer { channel.disconnect() }
channel.pendingAuthentication = SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity.id, username: identity.username,
selectedKeyID: key.id, requiresPassphrase: true)
channel.continueAuthentication(
identityID: identity.id, username: identity.username, password: nil,
passphrase: "saved", keyID: key.id, save: save, using: app.hostLibrary)
#expect(channel.pendingAuthentication == nil)
guard
case .software(let credential)? = try secrets.keyCredential(
for: key.credentialRef, storage: key.storage)
else {
Issue.record("The protected key must remain available")
return
}
#expect(credential.passphrase == (save ? "saved" : nil))
#expect(library.host(host.id) == host)
}
private func protectedKey() -> SSHKeyRef {
SSHKeyRef(label: "Protected", publicKey: "ssh-ed25519 fixture", hasPassphrase: true)
}
+2 -2
View File
@@ -206,7 +206,7 @@ struct MultiWindowTests {
let ssh = try #require(sshWorkspace.focusedPane.sshSession)
let sftp = try #require(sftpWorkspace.focusedPane.sftpChannel)
ssh.connectIfNeeded(using: app.hostLibrary)
sftp.connectIfNeeded(using: app.transfers)
sftp.connectIfNeeded(using: app.hostLibrary)
let sshPrompt = try #require(ssh.pendingAuthentication)
for _ in 0..<100 where sftp.pendingAuthentication == nil {
try await Task.sleep(for: .milliseconds(10))
@@ -221,7 +221,7 @@ struct MultiWindowTests {
destination.open(try #require(source.takeWorkspaceForMove(sshWorkspace.id)))
destination.open(try #require(source.takeWorkspaceForMove(sftpWorkspace.id)))
ssh.connectIfNeeded(using: app.hostLibrary)
sftp.connectIfNeeded(using: app.transfers)
sftp.connectIfNeeded(using: app.hostLibrary)
#expect(ssh.pendingAuthentication?.id == sshPrompt.id)
#expect(sftp.pendingAuthentication?.id == sftpPrompt.id)
#expect(sftp.path == "/srv")
-6
View File
@@ -197,12 +197,6 @@ struct RepositoryOutboxTests {
try context.save()
let repository = SwiftDataHostRepository(context: context)
#expect(
try repository.deletionImpact(for: doomed.id)
== HostDeletionImpact(
jumpHostReferences: 1, snippetReferences: 1, forwardingRules: 1
)
)
try repository.requestHostDeletion(doomed.id)
let hiddenSnapshot = try repository.snapshot()
+7 -1
View File
@@ -282,7 +282,13 @@ struct SFTPEditTests {
let client = EditClient()
let transfers: SFTPTransferCoordinator
let service: SFTPEditService
var connection: SFTPTransferConnection { .borrowed(id: hostID, channel: client) }
private lazy var context = SFTPTransferContext(
snapshot: SSHAdditionalConnectionSnapshot(
configuration: SSHConnectionConfiguration(
hostID: hostID, host: "fixture.invalid", port: 22,
username: "test", credentials: [], expectedHostKeys: [])),
channel: client, primaryRole: .ptyConnectionFallback)
var connection: SFTPTransferConnection { .context(context) }
init() {
let client = client
transfers = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { client })
+14 -10
View File
@@ -642,12 +642,15 @@ struct SFTPFeatureTests {
}
@Test
func borrowedConnectionRunsSeriallyWithoutReconnect() async {
func contextSerializesPrimaryChannelAndClosesItAfterOwnerRelease() async {
let hostID = UUID()
let connectionID = UUID()
let store = HostLibraryModel()
let gate = SFTPTransferClientGate()
let client = BorrowedSFTPTransferClient(gate: gate)
let client = PrimarySFTPTransferClient(gate: gate)
let context = SFTPTransferContext(
snapshot: SSHAdditionalConnectionSnapshot(
configuration: makeTestConfiguration(hostID: hostID, host: "fixture.invalid")),
channel: client, primaryRole: .ptyConnectionFallback)
let coordinator = SFTPTransferCoordinator(
store: store, globalLimit: 4, perHostLimit: 4
)
@@ -656,16 +659,15 @@ struct SFTPFeatureTests {
for index in 0..<2 {
coordinator.enqueue(
direction: .download, batchID: UUID(),
localURL: root.appendingPathComponent("borrowed-\(index)"),
remotePath: "/borrowed-\(index)", byteCount: 1,
hostID: hostID, connection: .borrowed(id: connectionID, channel: client),
localURL: root.appendingPathComponent("primary-\(index)"),
remotePath: "/primary-\(index)", byteCount: 1,
hostID: hostID, connection: .context(context),
scopedRoot: root, overwrite: true
)
}
await gate.waitForStarts(1)
#expect(await gate.maximumActive == 1)
#expect(coordinator.activeCount == 1)
await gate.releaseNext()
await gate.waitForStarts(2)
#expect(await gate.maximumActive == 1)
@@ -675,6 +677,8 @@ struct SFTPFeatureTests {
#expect(await client.connectCount == 0)
#expect(await client.disconnectCount == 0)
#expect(coordinator.sftpTransfers.allSatisfy { $0.state == .completed })
await context.releaseOwner()
#expect(await client.disconnectCount == 1)
}
@Test
@@ -1390,7 +1394,7 @@ private actor FakeSFTPTransferClient: SFTPClientProtocol {
) async throws { await gate.enter(host: host) }
}
private actor BorrowedSFTPTransferClient: SFTPClientProtocol {
private actor PrimarySFTPTransferClient: SFTPClientProtocol {
let gate: SFTPTransferClientGate
private(set) var connectCount = 0
private(set) var disconnectCount = 0
@@ -1413,12 +1417,12 @@ private actor BorrowedSFTPTransferClient: SFTPClientProtocol {
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws { await gate.enter(host: "borrowed") }
) async throws { await gate.enter(host: "primary") }
func downloadFile(
from remotePath: String, to localURL: URL, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws { await gate.enter(host: "borrowed") }
) async throws { await gate.enter(host: "primary") }
}
private actor ManifestSFTPClient: SFTPClientProtocol {
+2 -3
View File
@@ -29,11 +29,10 @@ nonisolated struct TailscaleSFTPChannelWireTests {
dnsName: "fixture.test.ts.net", online: true, sshHostKeys: ["fixture"])
], groupID: group.id)
let host = try #require(library.hosts.first)
let transfers = TransferLibraryModel(library: library)
#expect(transfers.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id))
#expect(library.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id))
let files = SFTPChannelModel(hostID: host.id)
defer { files.disconnect() }
files.connect(using: transfers)
files.connect(using: library)
for _ in 0..<2_000 {
if files.status == .connected || files.pendingAuthentication != nil { break }
if case .failed = files.status { break }
+3 -5
View File
@@ -13,7 +13,6 @@ struct TailscaleSessionRoutingTests {
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let library = HostLibraryModel(groups: [group])
let transfers = TransferLibraryModel(library: library)
let provider = RoutingProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers(
@@ -31,7 +30,7 @@ struct TailscaleSessionRoutingTests {
files?.disconnect()
}
terminal?.connect(using: library)
files?.connect(using: transfers)
files?.connect(using: library)
#expect(terminal?.pendingAuthentication == nil)
#expect(files?.pendingAuthentication == nil)
for _ in 0..<200 {
@@ -47,7 +46,6 @@ struct TailscaleSessionRoutingTests {
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let library = HostLibraryModel(groups: [group])
let transfers = TransferLibraryModel(library: library)
let provider = RoutingProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers(
@@ -64,7 +62,7 @@ struct TailscaleSessionRoutingTests {
files?.disconnect()
}
terminal?.connect(using: library)
files?.connect(using: transfers)
files?.connect(using: library)
let prompt = try #require(terminal?.pendingAuthentication ?? files?.pendingAuthentication)
#expect(prompt.usernameOnly && prompt.username == "root")
terminal?.continueAuthentication(
@@ -72,7 +70,7 @@ struct TailscaleSessionRoutingTests {
keyID: nil, save: true, using: library)
files?.continueAuthentication(
identityID: nil, username: "operator", password: nil, passphrase: nil,
keyID: nil, save: true, using: transfers)
keyID: nil, save: true, using: library)
for _ in 0..<200 {
if await provider.dialer.openedHost != nil { break }
try await Task.sleep(for: .milliseconds(10))
+27 -11
View File
@@ -563,7 +563,8 @@ struct WindowStateTests {
}
@Test
func newChatJoinsHistoryOnlyAfterItsFirstSend() {
func newChatJoinsHistoryOnlyAfterItsFirstSend() async {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
let draft = window.openNewAssistantChat()
@@ -571,24 +572,28 @@ struct WindowStateTests {
#expect(window.assistantLibrary.conversations.isEmpty)
draft.composer.draft = "Hello from a new chat"
let started = window.assistantLibrary.startDraftConversation()
let started = app.sendNewAgentDraft(in: window.assistantLibrary)
#expect(started === draft)
#expect(window.assistantLibrary.selectedConversation === draft)
#expect(window.assistantLibrary.conversations.map(\.id) == [draft.id])
#expect(window.assistantLibrary.draftConversation !== draft)
await started?.stopAndWait()
}
@Test
func sidebarDraftCreatesConversationOnlyOnFirstValidSend() {
func sidebarDraftCreatesConversationOnlyOnFirstValidSend() async {
let app = AppModel(hosts: HostLibraryModel())
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
let window = WindowModel(workspaces: [workspace])
#expect(window.sendAssistantSidebarDraft() == nil)
#expect(app.sendNewAgentConversation(window.sidebar.assistantDraft, in: window.assistantLibrary) == nil)
#expect(window.assistantLibrary.conversations.isEmpty)
window.sidebar.assistantDraft.composer.draft = " Explain this failure. "
let conversation = window.sendAssistantSidebarDraft()
let conversation = app.sendNewAgentConversation(
window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext)
if let conversation { window.assistantSidebarConversationDidStart(conversation) }
#expect(conversation?.messages.map(\.text) == ["Explain this failure."])
#expect(window.assistantLibrary.conversations.count == 1)
@@ -596,6 +601,7 @@ struct WindowStateTests {
#expect(window.sidebarAssistantConversation?.id == conversation?.id)
#expect(window.selectedDestination == .workspace(workspace.id))
#expect(window.sidebar.assistantDraft.isEmpty)
await conversation?.stopAndWait()
}
@Test
@@ -604,7 +610,8 @@ struct WindowStateTests {
let conversation = ConversationModel()
conversation.composer.draft = "Hello"
let library = AssistantLibraryModel()
_ = library.startConversation(conversation)
conversation.sendDraft()
library.insertAndSelect(conversation)
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
window.openAssistantInSidebar(conversation.id)
@@ -708,7 +715,8 @@ struct WindowStateTests {
/// conversation promoted out of it arrives in the Assistant destination carrying
/// nothing about a host it can no longer see.
@Test
func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() {
func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() async {
let app = AppModel(hosts: HostLibraryModel())
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app")
workspace.focusedPane.sftpChannel?.homeResolution = .unavailable
@@ -718,10 +726,14 @@ struct WindowStateTests {
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"])
let conversation = window.sendAssistantSidebarDraft()
let conversation = app.sendNewAgentConversation(
window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext)
if let conversation { window.assistantSidebarConversationDidStart(conversation) }
#expect(conversation?.composer.attachments.isEmpty == true)
#expect(conversation?.transcript.first?.contextSnapshot?.sessionContext?.contains("/srv/app") == true)
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"])
await conversation?.stopAndWait()
}
/// Leaving every Workspace used to freeze the last host and directory on
@@ -745,18 +757,21 @@ struct WindowStateTests {
/// the log goes with the message it belongs to rather than sitting in the
/// draft as text the user could half-delete.
@Test
func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() {
func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() async {
let app = AppModel(hosts: HostLibraryModel())
let conversation = ConversationModel()
conversation.attach(
ContextChip(kind: .output, label: "Connection error", content: "ssh: refused")
)
conversation.composer.draft = "Explain this"
_ = AssistantLibraryModel().startConversation(conversation)
#expect(app.sendNewAgentConversation(conversation, in: AssistantLibraryModel()) === conversation)
#expect(conversation.messages.first?.text == "Explain this")
#expect(conversation.transcript.first?.text.contains("ssh: refused") == true)
#expect(conversation.composer.attachments.isEmpty, "the attachment went with the turn")
#expect(conversation.transcript.first?.contextSnapshot != nil)
await conversation.stopAndWait()
}
@Test
@@ -792,7 +807,8 @@ struct WindowStateTests {
let conversation = ConversationModel()
conversation.composer.draft = "Explain this"
let library = AssistantLibraryModel()
_ = library.startConversation(conversation)
conversation.sendDraft()
library.insertAndSelect(conversation)
conversation.attach(
ContextChip(kind: .output, label: "Connection error", content: "ssh: refused")
)