diff --git a/AGENTS.md b/AGENTS.md index 82899966..008e45d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,10 @@ Use Swift Testing (`@Suite`, `@Test`, `#expect`) in the three test directories. macOS tests that present windows or activate the app must use `.requiresVisibleWindow` on their suite or individual test. They are skipped by default; keep this opt-in off for routine verification. To opt in from the command line, prefix the test command with `TEST_RUNNER_TERMIND_WINDOW_TESTS=1` (Xcode forwards it as `TERMIND_WINDOW_TESTS=1` to the test host), preferably with `-only-testing:TFMTests/` to limit the run. In Xcode, set `TERMIND_WINDOW_TESTS=1` in the test action's environment. Screenshot tests with existing dedicated opt-in variables keep those gates. Offscreen layout and logic tests remain enabled by default. +StoreKit tests require manual authorization and are also skipped by default. Run them explicitly with `TERMIND_STOREKIT_TESTS=1`, preferably with `-only-testing:TFMTests/StoreKitTrialTests`. + +Mobile key-window integration suites are skipped by default because they share UIKit's process-wide presentation state. Run them explicitly with `TEST_RUNNER_TERMIND_MOBILE_UI_TESTS=1` (Xcode forwards it as `TERMIND_MOBILE_UI_TESTS=1`), `-parallel-testing-enabled NO`, and narrow `-only-testing:TFITests/` filters. + ## Localization Guidelines Put user-facing app text in `Frameworks/Resources/Localizable.xcstrings` and bundle metadata in `InfoPlist.xcstrings`. Text owned by the agent runtime lives in `../AgentKit/Localizations/Localizable.xcstrings`; after editing it run `../AgentKit/Scripts/build-localizations.sh`, because `swift build` copies resources verbatim instead of compiling string catalogs. New or changed English strings require finished `zh-Hans` and `zh-Hant` translations in the same commit. Preserve placeholders and Chinese punctuation conventions. Before committing, run `Scripts/check-zh-localization.sh` (requires `jq`); it rejects invalid catalogs, missing or unfinished translations, and spacing errors. Run `AppLanguageTests` for localization behavior changes. diff --git a/FrameworkTests/AgentReplayPersistenceTests.swift b/FrameworkTests/AgentReplayPersistenceTests.swift index 6e7e9f2b..547720ce 100644 --- a/FrameworkTests/AgentReplayPersistenceTests.swift +++ b/FrameworkTests/AgentReplayPersistenceTests.swift @@ -6,7 +6,7 @@ import TermindDomain import TermindPersistence import Testing -@Suite @MainActor +@Suite(.timeLimit(.minutes(1))) @MainActor struct AgentReplayPersistenceTests { @Test func snapshotsAndFrozenResultsSurviveRestoreAndDeletion() async throws { @@ -111,6 +111,162 @@ struct AgentReplayPersistenceTests { #expect(restored.runs.first?.failure == (state == .failed ? "preparation failed" : nil)) } + @Test + func queuedCancellationAtomicallyAdoptsEditedStagingAndReloadsCapturedPayload() async throws { + let container = try TermindPersistence.makeContainer(inMemory: true) + let repository = SwiftDataAgentRunRepository(context: container.mainContext) + let conversationID = UUID() + let original = AgentTranscriptMessage(role: .user, text: "before", authoredText: "before") + try await repository.save( + AgentRunSnapshot( + conversationID: conversationID, state: .completed, + permissionMode: .askForApproval, messages: [original] + )) + let stored = try #require(try await repository.loadConversations().first) + let store = AssistantConversationStore() + store.restore([stored]) + let conversation = try #require(store.conversations.first) + let context = AgentTurnContextSnapshot( + sessionContext: "captured host", skillCatalog: "captured skills", + planContract: "captured plan", toolAvailability: ["terminal_suggest": false] + ) + let image = AgentImageAttachment( + label: "capture.png", mimeType: "image/png", data: Data([1, 2, 3]), + pixelWidth: 2, pixelHeight: 2 + ) + let attachment = AgentContextAttachment( + id: UUID(), kind: .file, label: "capture.txt", + contentTypeIdentifier: "public.plain-text", suggestedFilename: "capture.txt", + data: Data("captured payload".utf8) + ) + let replacement = AgentTranscriptMessage( + role: .user, text: "after", authoredText: "After authored", + images: [image], contextAttachments: [attachment], contextSnapshot: context, + createdAt: .now + ) + let operations = AssistantConversationOperations(repository: repository) + let composed = try #require( + try await operations.edit( + conversation, messageID: original.id, replacement: replacement + )) + let staging = try #require(conversation.runSummaries[replacement.id]) + let calls = ReplayModelCalls() + conversation.start( + runtime: AgentRuntime( + model: CountingReplayModel(calls: calls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository + ), + request: AgentRunRequest( + conversationID: conversation.id, promptID: composed.id, + prompt: composed.text, authoredPrompt: composed.authoredText, + promptImages: composed.images, + promptContextAttachments: composed.contextAttachments, + permissionMode: .fullAccess, contextSnapshot: context + ), + repository: repository + ) + + await conversation.stopAndWait() + + #expect(await calls.count == 0) + #expect(conversation.runSummaries[replacement.id] == nil) + let freshRepository = SwiftDataAgentRunRepository(context: ModelContext(container)) + let restored = try #require(try await freshRepository.loadConversations().first) + let run = try #require(restored.runs.first) + #expect(restored.runs.count == 1) + #expect(run.id != replacement.id) + #expect(run.state == .cancelled) + #expect(run.startedAt >= staging.startedAt) + #expect(run.finishedAt.map { $0 >= run.startedAt } == true) + #expect(restored.messageRunIDs[replacement.id] == run.id) + let message = try #require(restored.messages.first) + #expect(message.id == replacement.id) + #expect(message.text == replacement.text) + #expect(message.authoredText == replacement.authoredText) + #expect(message.images == [image]) + #expect(message.contextAttachments == [attachment]) + #expect(message.contextSnapshot == context) + #expect(message.createdAt <= (run.finishedAt ?? .distantPast)) + let runRows = try ModelContext(container).fetch(FetchDescriptor()) + #expect(runRows.count == 1) + #expect(runRows.first?.permissionMode == AgentPermissionMode.fullAccess.rawValue) + } + + @Test + func capturedConfigurationFailureAdoptsEditedStagingOnlyAfterDurableSave() async throws { + let container = try TermindPersistence.makeContainer(inMemory: true) + let repository = SwiftDataAgentRunRepository(context: container.mainContext) + let conversationID = UUID() + let image = AgentImageAttachment( + label: "capture.png", mimeType: "image/png", data: Data([4, 5, 6]), + pixelWidth: 2, pixelHeight: 2 + ) + let attachment = AgentContextAttachment( + id: UUID(), kind: .file, label: "capture.txt", + contentTypeIdentifier: "public.plain-text", suggestedFilename: "capture.txt", + data: Data("captured payload".utf8) + ) + let original = AgentTranscriptMessage( + role: .user, text: "before", authoredText: "before", + images: [image], contextAttachments: [attachment] + ) + try await repository.save( + AgentRunSnapshot( + conversationID: conversationID, state: .completed, + permissionMode: .askForApproval, messages: [original] + )) + let stored = try #require(try await repository.loadConversations().first) + let store = AssistantConversationStore() + store.restore([stored]) + let conversation = try #require(store.conversations.first) + conversation.permissionMode = .fullAccess + let controller = AssistantExecutionController( + ai: AIConfigurationModel(), skills: SkillLibraryModel(), repository: repository, + authorize: { true }, + makeRegistry: { _, _, _, _, _ in AgentToolRegistry([]) }, + title: { _ in "Title" } + ) + let operations = AssistantConversationOperations(repository: repository) + let prepared = try #require( + controller.prepareAgentEdit( + conversation, messageID: original.id, text: "after", + context: SessionContext(hostName: "captured-host", workingDirectory: "/captured"), + terminalSuggestionsAvailable: true + )) + + #expect(try await controller.performAgentEdit(prepared, in: conversation, operations: operations)) + let replacement = try #require(conversation.transcript.first) + let staging = try #require(conversation.runSummaries[replacement.id]) + await conversation.stopAndWait() + + #expect(conversation.runState == .failed) + #expect(conversation.runSummaries[replacement.id] == nil) + + let freshRepository = SwiftDataAgentRunRepository(context: ModelContext(container)) + let restored = try #require(try await freshRepository.loadConversations().first) + let run = try #require(restored.runs.first) + let message = try #require(restored.messages.first) + #expect(restored.runs.count == 1) + #expect(run.id != replacement.id) + #expect(run.state == .failed) + #expect(run.failure == String(localized: "No Chat model is configured.")) + #expect(run.startedAt >= staging.startedAt) + #expect(run.finishedAt.map { $0 >= run.startedAt } == true) + #expect(restored.messageRunIDs[replacement.id] == run.id) + #expect(message.id == replacement.id) + #expect(message.text == replacement.text) + #expect(message.authoredText == "after") + #expect(message.images == [image]) + #expect(message.contextAttachments == [attachment]) + #expect(message.contextSnapshot?.sessionContext?.contains("captured-host") == true) + #expect(message.contextSnapshot?.toolAvailability?["terminal_suggest"] == true) + #expect(message.createdAt <= (run.finishedAt ?? .distantPast)) + let runRows = try ModelContext(container).fetch(FetchDescriptor()) + #expect(runRows.count == 1) + #expect(runRows.first?.permissionMode == AgentPermissionMode.fullAccess.rawValue) + } + @Test func v15MigratesWithoutInventingHistoricalContext() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -140,3 +296,22 @@ struct AgentReplayPersistenceTests { try container.mainContext.save() } } + +private actor ReplayModelCalls { + private(set) var count = 0 + func called() { count += 1 } +} + +private struct CountingReplayModel: AgentModelStreaming { + let calls: ReplayModelCalls + + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + await calls.called() + continuation.yield(.finished(.completed)) + continuation.finish() + } + } + } +} diff --git a/FrameworkTests/AgentSteeringTests.swift b/FrameworkTests/AgentSteeringTests.swift index 1c37fbfe..b7bae457 100644 --- a/FrameworkTests/AgentSteeringTests.swift +++ b/FrameworkTests/AgentSteeringTests.swift @@ -193,14 +193,17 @@ struct AgentSteeringTests { sessionContext: "host A", skillCatalog: "fixed skills", planContract: "fixed mode", toolAvailability: ["terminal_suggest": false, "future_surface_tool": false], memorySessionID: memorySessionID) + let repository = InMemoryAgentRunRepository() conversation.start( runtime: AgentRuntime( model: HangingAgentModel(), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "start", permissionMode: .askForApproval, - contextSnapshot: first)) + contextSnapshot: first), + repository: repository) conversation.composer.draft = "inspect here" conversation.submitSteeringDraft( context: SessionContext(hostName: "B", workingDirectory: "/srv"), @@ -233,15 +236,18 @@ struct AgentSteeringTests { } private func startRun(_ conversation: ConversationModel) { + let repository = InMemoryAgentRunRepository() conversation.start( runtime: AgentRuntime( model: HangingAgentModel(), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "start", permissionMode: .askForApproval - ) + ), + repository: repository ) } } diff --git a/FrameworkTests/AssistantConversationOperationsTests.swift b/FrameworkTests/AssistantConversationOperationsTests.swift index 8a1d2f85..bf41d14b 100644 --- a/FrameworkTests/AssistantConversationOperationsTests.swift +++ b/FrameworkTests/AssistantConversationOperationsTests.swift @@ -3,7 +3,7 @@ import Foundation import TermindApplication import Testing -@Suite @MainActor +@Suite(.timeLimit(.minutes(1))) @MainActor struct AssistantConversationOperationsTests { private func fixture() async throws -> ( InMemoryAgentRunRepository, ConversationModel, AssistantConversationOperations @@ -203,20 +203,265 @@ struct AssistantConversationOperationsTests { } @Test - func immediateStopCannotStartAQueuedRuntime() async { + func immediateStopAndWaitPersistsCapturedCancellationWithoutCallingProviderOrTools() async throws { let calls = AssistantModelCalls() + let tools = AssistantToolCalls() + let repository = InMemoryAgentRunRepository() let conversation = ConversationModel() + let promptID = UUID() + let context = AgentTurnContextSnapshot( + sessionContext: "captured host", skillCatalog: "captured skills", + planContract: "captured plan", toolAvailability: ["terminal_suggest": false] + ) + let tool = AnyAgentTool( + descriptor: AgentToolDescriptor( + name: "never", summary: "Never runs", inputSchema: .object([:]), + target: .local, approvalPolicy: .approve + ) + ) { invocation, _ in + await tools.called() + return AgentToolResult(callID: invocation.call.id, content: "unexpected") + } let runtime = AgentRuntime( - model: CountingAssistantModel(calls: calls), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .deny("test") })) + model: CountingAssistantModel(calls: calls), tools: [tool], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .deny("test") }), + repository: repository) conversation.start( runtime: runtime, request: AgentRunRequest( - conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval)) - conversation.stop() - for _ in 0..<20 { await Task.yield() } + conversationID: conversation.id, promptID: promptID, prompt: "go", + authoredPrompt: "Go now", permissionMode: .fullAccess, + contextSnapshot: context), + repository: repository) + + await conversation.stopAndWait() + #expect(await calls.count == 0) + #expect(await tools.count == 0) #expect(!conversation.isStreaming) + #expect(conversation.runState == .cancelled) + let stored = try #require(await repository.loadConversations().first) + let run = try #require(stored.runs.first) + let snapshot = try #require(await repository.snapshot(run.id)) + #expect(snapshot.state == .cancelled) + #expect(snapshot.permissionMode == .fullAccess) + #expect(snapshot.messages.last?.id == promptID) + #expect(snapshot.messages.last?.text == "go") + #expect(snapshot.messages.last?.authoredText == "Go now") + #expect(snapshot.messages.last?.contextSnapshot == context) + #expect(snapshot.messages.last.map { $0.createdAt <= (snapshot.finishedAt ?? .distantPast) } == true) + #expect(conversation.messageRunIDs[promptID] == run.id) + #expect(conversation.runSummaries[run.id]?.state == .cancelled) + } + + @Test + func stopThenStopAndWaitJoinsTheQueuedCancellationSave() async { + let calls = AssistantModelCalls() + let repository = TerminalSaveGateRepository() + let conversation = ConversationModel() + let runtime = AgentRuntime( + model: CountingAssistantModel(calls: calls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .deny("test") }), + repository: repository) + conversation.start( + runtime: runtime, + request: AgentRunRequest( + conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval), + repository: repository) + + conversation.stop() + await repository.waitForTerminalSave() + let join = JoinProbe() + let waiter = Task { @MainActor in + join.markStarted() + await conversation.stopAndWait() + join.markFinished() + } + await join.waitUntilStarted() + #expect(!join.finished) + + await repository.releaseTerminalSaves() + await waiter.value + #expect(join.finished) + #expect(await calls.count == 0) + #expect(await repository.savedTerminalState == .cancelled) + } + + @Test + func queuedStopPersistsAfterConversationPresentationIsReleased() async { + let calls = AssistantModelCalls() + let repository = TerminalSaveGateRepository() + weak var released: ConversationModel? + do { + var conversation: ConversationModel? = ConversationModel() + released = conversation + let runtime = AgentRuntime( + model: CountingAssistantModel(calls: calls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository + ) + conversation?.start( + runtime: runtime, + request: AgentRunRequest( + conversationID: conversation!.id, prompt: "go", + permissionMode: .askForApproval), + repository: repository + ) + conversation?.stop() + conversation = nil + } + + #expect(released == nil) + await repository.waitForTerminalSave() + #expect(await calls.count == 0) + await repository.releaseTerminalSaves() + await repository.waitForTerminalSaved() + } + + @Test + func directStartCancelsAndPersistsTheDisplacedQueuedOwner() async { + let oldCalls = AssistantModelCalls() + let oldRepository = TerminalSaveGateRepository() + let conversation = ConversationModel() + conversation.start( + runtime: AgentRuntime( + model: CountingAssistantModel(calls: oldCalls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: oldRepository + ), + request: AgentRunRequest( + conversationID: conversation.id, prompt: "old", permissionMode: .askForApproval), + repository: oldRepository + ) + + let newCalls = AssistantModelCalls() + let newRepository = InMemoryAgentRunRepository() + conversation.start( + runtime: AgentRuntime( + model: HangingCountingAssistantModel(calls: newCalls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: newRepository + ), + request: AgentRunRequest( + conversationID: conversation.id, prompt: "new", permissionMode: .askForApproval), + repository: newRepository + ) + + await oldRepository.waitForTerminalSave() + await newCalls.waitUntilCalled() + #expect(await oldCalls.count == 0) + #expect(conversation.runState == .running) + await oldRepository.releaseTerminalSaves() + await oldRepository.waitForTerminalSaved() + #expect(await oldRepository.savedTerminalState == .cancelled) + await conversation.stopAndWait() + } + + @Test + func stopAndWaitCancelsPendingApprovalAndJoinsItsTerminalSave() async { + let tools = AssistantToolCalls() + let repository = TerminalSaveGateRepository() + let conversation = ConversationModel() + let tool = AnyAgentTool( + descriptor: AgentToolDescriptor( + name: "held", summary: "Held", inputSchema: .object([:]), + target: .local, approvalPolicy: .ask + ) + ) { invocation, _ in + await tools.called() + return AgentToolResult(callID: invocation.call.id, content: "unexpected") + } + conversation.start( + runtime: AgentRuntime( + model: ApprovalRequestingAssistantModel(), tools: [tool], + approval: AgentApprovalBroker( + reviewer: nil, + manualApproval: { [weak conversation] request in + guard let conversation else { return .deny("gone") } + return await conversation.requestApproval(request) + } + ), + repository: repository + ), + request: AgentRunRequest( + conversationID: conversation.id, prompt: "approve", + permissionMode: .askForApproval), + repository: repository + ) + await waitUntil { conversation.pendingApproval != nil } + + let releaseTerminalSave = Task { @MainActor in + await repository.waitForTerminalSave() + #expect(conversation.pendingApprovals.isEmpty) + await repository.releaseTerminalSaves() + } + await conversation.stopAndWait() + await releaseTerminalSave.value + + #expect(conversation.pendingApprovals.isEmpty) + #expect(await tools.count == 0) + #expect(await repository.savedTerminalState == .cancelled) + #expect(conversation.runState == .cancelled) + } + + @Test + func runningStopAndWaitDrainsOldPersistenceWithoutClobberingReplacement() async { + let oldCalls = AssistantModelCalls() + let oldRepository = TerminalSaveGateRepository() + let conversation = ConversationModel() + conversation.start( + runtime: AgentRuntime( + model: HangingCountingAssistantModel(calls: oldCalls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: oldRepository + ), + request: AgentRunRequest( + conversationID: conversation.id, prompt: "old", permissionMode: .askForApproval), + repository: oldRepository + ) + await oldCalls.waitUntilCalled() + await waitUntil { conversation.runSummaries.values.contains(where: { $0.state == .running }) } + let oldRunID = try! #require(conversation.runSummaries.values.first(where: { $0.state == .running })?.id) + let reviewInvocation = AgentToolInvocation( + runID: oldRunID, + call: AgentToolCall(id: "reviewed", name: "reviewed", arguments: .object([:])) + ) + let reviewDescriptor = AgentToolDescriptor( + name: "reviewed", summary: "Reviewed", inputSchema: .object([:]), + target: .local, approvalPolicy: .ask + ) + let reviewRequest = AgentApprovalRequest( + invocation: reviewInvocation, descriptor: reviewDescriptor, userIntent: "review" + ) + conversation.handle(.toolProposed(reviewInvocation, reviewDescriptor)) + conversation.handle(.reviewStarted(reviewRequest)) + #expect(conversation.runState == .reviewing) + + let replacementCalls = AssistantModelCalls() + let replacementRepository = InMemoryAgentRunRepository() + let replaceWhileOldSaveIsBlocked = Task { @MainActor in + await oldRepository.waitForTerminalSave() + conversation.start( + runtime: AgentRuntime( + model: HangingCountingAssistantModel(calls: replacementCalls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: replacementRepository + ), + request: AgentRunRequest( + conversationID: conversation.id, prompt: "new", permissionMode: .askForApproval), + repository: replacementRepository + ) + await replacementCalls.waitUntilCalled() + await waitUntil { conversation.runState == .running } + await oldRepository.releaseTerminalSaves() + } + + await conversation.stopAndWait() + await replaceWhileOldSaveIsBlocked.value + #expect(conversation.runState == .running) + #expect(conversation.toolCards.first(where: { $0.id == reviewInvocation.id })?.state == .interrupted) + #expect(await oldRepository.savedTerminalState == .cancelled) + await conversation.stopAndWait() } @Test func deletingRemovesDurableConversation() async throws { @@ -309,10 +554,98 @@ private actor RejectingConversationRepository: AgentRunPersisting { } private actor AssistantModelCalls { + var count = 0 + private var waiters: [CheckedContinuation] = [] + + func called() { + count += 1 + let pending = waiters + waiters.removeAll() + for waiter in pending { waiter.resume() } + } + + func waitUntilCalled() async { + guard count == 0 else { return } + await withCheckedContinuation { waiters.append($0) } + } +} + +private actor AssistantToolCalls { var count = 0 func called() { count += 1 } } +@MainActor +private final class JoinProbe { + private(set) var finished = false + private var started = false + private var startWaiters: [CheckedContinuation] = [] + + func markStarted() { + started = true + let pending = startWaiters + startWaiters.removeAll() + for waiter in pending { waiter.resume() } + } + + func waitUntilStarted() async { + guard !started else { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func markFinished() { finished = true } +} + +private actor TerminalSaveGateRepository: AgentRunPersisting { + private var terminalSaveEntered = false + private var terminalSaveWaiters: [CheckedContinuation] = [] + private var terminalReleaseWaiters: [CheckedContinuation] = [] + private var terminalSavedWaiters: [CheckedContinuation] = [] + private(set) var savedTerminalState: AgentRunState? + + func save(_ snapshot: AgentRunSnapshot) async { + guard snapshot.state.isTerminal else { return } + terminalSaveEntered = true + let entered = terminalSaveWaiters + terminalSaveWaiters.removeAll() + for waiter in entered { waiter.resume() } + await withCheckedContinuation { terminalReleaseWaiters.append($0) } + savedTerminalState = snapshot.state + let saved = terminalSavedWaiters + terminalSavedWaiters.removeAll() + for waiter in saved { waiter.resume() } + } + + func waitForTerminalSave() async { + guard !terminalSaveEntered else { return } + await withCheckedContinuation { terminalSaveWaiters.append($0) } + } + + func releaseTerminalSaves() { + let pending = terminalReleaseWaiters + terminalReleaseWaiters.removeAll() + for waiter in pending { waiter.resume() } + } + + func waitForTerminalSaved() async { + guard savedTerminalState == nil else { return } + await withCheckedContinuation { terminalSavedWaiters.append($0) } + } + + func record(_ event: AgentEvent, runID: UUID) {} + func persistCompaction(_ record: AgentCompactionRecord, conversationID: UUID) {} + func loadConversations() -> [StoredAgentConversation] { [] } + func deleteConversation(_ id: UUID) {} + func setTitle(_ title: String, conversationID: UUID) {} + func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {} + func reorderConversations(_ updates: [StoredAgentConversationOrder]) {} + func truncateConversation( + _ id: UUID, fromUserMessageID messageID: UUID, + replacingWith message: AgentTranscriptMessage + ) {} + func createConversation(_ conversation: StoredAgentConversation) {} +} + private struct CountingAssistantModel: AgentModelStreaming { let calls: AssistantModelCalls func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { @@ -325,3 +658,33 @@ private struct CountingAssistantModel: AgentModelStreaming { } } } + +private struct HangingCountingAssistantModel: AgentModelStreaming { + let calls: AssistantModelCalls + + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + await calls.called() + continuation.yield(.textDelta("working")) + try? await Task.sleep(for: .seconds(30)) + continuation.finish() + } + } + } +} + +private struct ApprovalRequestingAssistantModel: AgentModelStreaming { + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.yield(.toolCallDelta(id: "held", name: "held", arguments: "{}")) + continuation.yield(.finished(.toolCalls)) + continuation.finish() + } + } +} + +@MainActor +private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async { + while !condition() { await Task.yield() } +} diff --git a/FrameworkTests/AssistantExecutionControllerTests.swift b/FrameworkTests/AssistantExecutionControllerTests.swift index fef4d96e..bbcace85 100644 --- a/FrameworkTests/AssistantExecutionControllerTests.swift +++ b/FrameworkTests/AssistantExecutionControllerTests.swift @@ -85,6 +85,151 @@ struct AssistantExecutionControllerTests { #expect(await repository.snapshots.last?.state == .failed) } + @Test + func missingModelFailureStaysOwnedUntilSaveAndAllCancellationPathsJoinIt() async throws { + let repository = RecordingAgentRunRepository(blocksFirstSave: true) + let controller = makeController(repository: repository) + let conversation = ConversationModel() + conversation.composer.draft = "inspect" + + #expect(controller.sendAgentDraft(conversation)) + await repository.waitUntilSaveEntered() + + #expect(conversation.isPreparingAgentRun) + #expect(conversation.isPersistingAgentPreparationFailure) + #expect(conversation.runState != .failed) + #expect(conversation.runSummaries.isEmpty) + + conversation.stop() + let probe = JoinProbe(expectedStarts: 2) + let joinedStop = Task { @MainActor in + probe.started() + await conversation.stopAndWait() + probe.completed() + } + let joinedControllerCancellation = Task { @MainActor in + probe.started() + await controller.cancelPendingRuns().value + probe.completed() + } + try await waitUntil { probe.startedCount == probe.expectedStarts } + + #expect(conversation.isPreparingAgentRun) + #expect(await repository.snapshots.map(\.state) == [.failed]) + #expect(await repository.completedSnapshots.isEmpty) + #expect(probe.completedCount == 0) + + await repository.releaseSave() + await joinedStop.value + await joinedControllerCancellation.value + + #expect(!conversation.isPreparingAgentRun) + #expect(!conversation.isPersistingAgentPreparationFailure) + #expect(conversation.runState == .failed) + #expect(await repository.snapshots.map(\.state) == [.failed]) + #expect(await repository.completedSnapshots.map(\.state) == [.failed]) + #expect(probe.completedCount == 2) + } + + @Test + func capturedFailureSaveOutlivesReleasedControllerAndConversationHandles() async throws { + let repository = RecordingAgentRunRepository(blocksFirstSave: true) + var controller: AssistantExecutionController? = makeController(repository: repository) + var conversation: ConversationModel? = ConversationModel() + conversation?.composer.draft = "persist me" + let releasedController = WeakReference(controller) + let releasedConversation = WeakReference(conversation) + + #expect(controller?.sendAgentDraft(try #require(conversation)) == true) + await repository.waitUntilSaveEntered() + controller = nil + conversation = nil + + #expect(releasedController.value != nil) + #expect(releasedConversation.value != nil) + + await repository.releaseSave() + await repository.waitUntilSaveCompleted() + try await waitUntil { + releasedController.value == nil && releasedConversation.value == nil + } + #expect(await repository.completedSnapshots.map(\.state) == [.failed]) + } + + @Test + func missingCredentialAfterMemoryPreparationProjectsOnlyAfterSaveCompletes() async throws { + let memoryStore = SnapshotBarrierMemoryStore() + let memory = MemoryModel(storage: memoryStore) + await memory.setEnabled(true) + let repository = RecordingAgentRunRepository(blocksFirstSave: true) + let (ai, _) = configuredAI(secret: nil) + let controller = makeController(ai: ai, repository: repository, memory: memory) + let conversation = ConversationModel() + conversation.composer.draft = "inspect" + + #expect(controller.sendAgentDraft(conversation)) + await memoryStore.waitUntilSnapshotEntered() + await memoryStore.releaseSnapshot() + await repository.waitUntilSaveEntered() + + #expect(conversation.isPreparingAgentRun) + #expect(conversation.isPersistingAgentPreparationFailure) + #expect(conversation.runState != .failed) + + await repository.releaseSave() + try await waitUntil { !conversation.isPreparingAgentRun } + + #expect(conversation.runState == .failed) + #expect(await repository.completedSnapshots.map(\.state) == [.failed]) + } + + @Test + func archiveAndDeleteWaitForCapturedFailurePersistence() async throws { + do { + let repository = RecordingAgentRunRepository(blocksFirstSave: true) + let controller = makeController(repository: repository) + let operations = AssistantConversationOperations(repository: repository) + let conversation = ConversationModel() + conversation.composer.draft = "archive me" + + #expect(controller.sendAgentDraft(conversation)) + await repository.waitUntilSaveEntered() + let archive = Task { + try await operations.setArchived(true, conversation: conversation) + } + await Task.yield() + + #expect(await repository.metadataMutationCount == 0) + #expect(conversation.archivedAt == nil) + + await repository.releaseSave() + try await archive.value + #expect(await repository.metadataMutationCount == 1) + #expect(await repository.events == ["save-attempt", "save-completed", "metadata"]) + #expect(conversation.archivedAt != nil) + } + + do { + let repository = RecordingAgentRunRepository(blocksFirstSave: true) + let controller = makeController(repository: repository) + let operations = AssistantConversationOperations(repository: repository) + let conversation = ConversationModel() + conversation.composer.draft = "delete me" + + #expect(controller.sendAgentDraft(conversation)) + await repository.waitUntilSaveEntered() + let deletion = Task { try await operations.delete(conversation) } + await Task.yield() + + #expect(await repository.deletionCount == 0) + + await repository.releaseSave() + #expect(try await deletion.value.deleted) + #expect(await repository.deletionCount == 1) + #expect(await repository.events == ["save-attempt", "save-completed", "delete"]) + } + } + @Test func immediateStopInvalidatesPreparationBeforeTheMemoryBarrierIsReleased() async throws { let memoryStore = SnapshotBarrierMemoryStore() @@ -493,6 +638,7 @@ struct AssistantExecutionControllerTests { #expect(edited.authoredText == "after") #expect(edited.contextSnapshot?.sessionContext?.contains("captured-host") == true) #expect(edited.contextSnapshot?.planContract == TermindAgentPolicy.planContract) + await conversation.stopAndWait() #expect(conversation.runState == .failed) let failed = try #require(conversation.runSummaries.values.first) #expect(conversation.runSummaries.count == 1) @@ -502,6 +648,73 @@ struct AssistantExecutionControllerTests { #expect(conversation.runSummaries[edited.id] == nil) } + @Test + func failedReplacementSaveCannotBeSupersededOrMutatedBeforeItCompletes() async throws { + let repository = RecordingAgentRunRepository(blocksFirstSave: true) + let controller = makeController(repository: repository) + let operations = AssistantConversationOperations(repository: repository) + let original = AgentTranscriptMessage(role: .user, text: "opening", authoredText: "opening") + let answer = AgentTranscriptMessage(role: .assistant, text: "answer") + let later = AgentTranscriptMessage(role: .user, text: "before", authoredText: "before") + let conversation = ConversationModel( + messages: [ + ChatMessage(id: original.id, role: .user, text: original.text), + ChatMessage(id: answer.id, role: .assistant, text: answer.text), + ChatMessage(id: later.id, role: .user, text: later.text), + ], transcript: [original, answer, later]) + let prepared = try #require( + controller.prepareAgentEdit(conversation, messageID: later.id, text: "after")) + + #expect(try await controller.performAgentEdit(prepared, in: conversation, operations: operations)) + await repository.waitUntilSaveEntered() + let edited = try #require(conversation.transcript.last) + + #expect(conversation.messageRunIDs[edited.id] == edited.id) + #expect(conversation.transcript.contains(where: { $0.id == answer.id })) + #expect(controller.prepareAgentEdit(conversation, messageID: edited.id, text: "newer") == nil) + #expect( + try await operations.edit(conversation, messageID: edited.id, text: "bypass") == nil) + #expect(try await operations.fork(conversation, at: answer.id, title: "Bypass") == nil) + #expect(await repository.truncationCount == 1) + + await repository.releaseSave() + await conversation.stopAndWait() + + #expect(conversation.runState == .failed) + #expect(conversation.messageRunIDs[edited.id] != edited.id) + #expect(conversation.runSummaries[edited.id] == nil) + } + + @Test + func failedSnapshotSaveRetiresPreparationWithoutAdoptingStaging() async throws { + let repository = RecordingAgentRunRepository(blocksFirstSave: true, failsFirstSave: true) + let controller = makeController(repository: repository) + let operations = AssistantConversationOperations(repository: repository) + let original = AgentTranscriptMessage(role: .user, text: "before", authoredText: "before") + let conversation = ConversationModel( + messages: [ChatMessage(id: original.id, role: .user, text: original.text)], + transcript: [original]) + let prepared = try #require( + controller.prepareAgentEdit(conversation, messageID: original.id, text: "after")) + + #expect(try await controller.performAgentEdit(prepared, in: conversation, operations: operations)) + await repository.waitUntilSaveEntered() + let edited = try #require(conversation.transcript.first) + let staging = try #require(conversation.runSummaries[edited.id]) + #expect(conversation.messageRunIDs[edited.id] == edited.id) + + await repository.releaseSave() + try await waitUntil { !conversation.isPreparingAgentRun } + + #expect(!conversation.isPersistingAgentPreparationFailure) + #expect(conversation.messageRunIDs[edited.id] == edited.id) + #expect(conversation.runSummaries[edited.id] == staging) + #expect(conversation.runSummaries.values.allSatisfy { $0.state != .failed }) + #expect(conversation.messages.last?.role == .assistant) + #expect(conversation.messages.last?.text == CocoaError(.fileWriteNoPermission).localizedDescription) + #expect(await repository.completedSnapshots.isEmpty) + } + @Test func cancellationAfterSuccessfulEditAdoptsTheInterruptedRunProvenance() async throws { let memoryStore = SnapshotBarrierMemoryStore() @@ -573,6 +786,33 @@ struct AssistantExecutionControllerTests { private struct TestTimeoutError: Error {} +private final class WeakReference { + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} + +@MainActor +private final class JoinProbe { + let expectedStarts: Int + private(set) var startedCount = 0 + private(set) var completedCount = 0 + + init(expectedStarts: Int) { + self.expectedStarts = expectedStarts + } + + func started() { + startedCount += 1 + } + + func completed() { + completedCount += 1 + } +} + @MainActor private final class RegistryCapture { var modelID: String? @@ -635,14 +875,21 @@ private actor SnapshotBarrierMemoryStore: MemoryPersisting { private actor RecordingAgentRunRepository: AgentRunPersisting { private(set) var snapshots: [AgentRunSnapshot] = [] + private(set) var completedSnapshots: [AgentRunSnapshot] = [] + private(set) var events: [String] = [] private(set) var truncationReplacement: AgentTranscriptMessage? + private(set) var truncationCount = 0 + private(set) var metadataMutationCount = 0 + private(set) var deletionCount = 0 private let failTruncation: Bool private let blocksFirstSave: Bool + private let failsFirstSave: Bool private let blocksTruncation: Bool private var saveEntered = false private var saveReleased = false private var saveEntryWaiters: [CheckedContinuation] = [] private var saveReleaseWaiters: [CheckedContinuation] = [] + private var saveCompletionWaiters: [CheckedContinuation] = [] private var snapshotWaiters: [(state: AgentRunState, continuation: CheckedContinuation)] = [] private var truncationEntered = false private var truncationReleased = false @@ -651,15 +898,17 @@ private actor RecordingAgentRunRepository: AgentRunPersisting { init( failTruncation: Bool = false, blocksFirstSave: Bool = false, - blocksTruncation: Bool = false + blocksTruncation: Bool = false, failsFirstSave: Bool = false ) { self.failTruncation = failTruncation self.blocksFirstSave = blocksFirstSave self.blocksTruncation = blocksTruncation + self.failsFirstSave = failsFirstSave } - func save(_ snapshot: AgentRunSnapshot) async { + func save(_ snapshot: AgentRunSnapshot) async throws { snapshots.append(snapshot) + events.append("save-attempt") var pendingSnapshotWaiters: [(state: AgentRunState, continuation: CheckedContinuation)] = [] for waiter in snapshotWaiters { if waiter.state == snapshot.state { @@ -669,14 +918,23 @@ private actor RecordingAgentRunRepository: AgentRunPersisting { } } snapshotWaiters = pendingSnapshotWaiters - guard blocksFirstSave, snapshots.count == 1 else { return } - saveEntered = true - let entryWaiters = saveEntryWaiters - saveEntryWaiters.removeAll() - for waiter in entryWaiters { waiter.resume() } - if !saveReleased { - await withCheckedContinuation { saveReleaseWaiters.append($0) } + if blocksFirstSave, snapshots.count == 1 { + saveEntered = true + let entryWaiters = saveEntryWaiters + saveEntryWaiters.removeAll() + for waiter in entryWaiters { waiter.resume() } + if !saveReleased { + await withCheckedContinuation { saveReleaseWaiters.append($0) } + } } + if failsFirstSave, snapshots.count == 1 { + throw CocoaError(.fileWriteNoPermission) + } + completedSnapshots.append(snapshot) + events.append("save-completed") + let completionWaiters = saveCompletionWaiters + saveCompletionWaiters.removeAll() + for waiter in completionWaiters { waiter.resume() } } func waitUntilSaveEntered() async { @@ -689,6 +947,11 @@ private actor RecordingAgentRunRepository: AgentRunPersisting { await withCheckedContinuation { snapshotWaiters.append((state, $0)) } } + func waitUntilSaveCompleted() async { + if !completedSnapshots.isEmpty { return } + await withCheckedContinuation { saveCompletionWaiters.append($0) } + } + func releaseSave() { saveReleased = true let waiters = saveReleaseWaiters @@ -711,14 +974,21 @@ private actor RecordingAgentRunRepository: AgentRunPersisting { func record(_ event: AgentEvent, runID: UUID) {} func persistCompaction(_ record: AgentCompactionRecord, conversationID: UUID) {} func loadConversations() -> [StoredAgentConversation] { [] } - func deleteConversation(_ id: UUID) {} + func deleteConversation(_ id: UUID) { + deletionCount += 1 + events.append("delete") + } func setTitle(_ title: String, conversationID: UUID) {} - func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {} + func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) { + metadataMutationCount += 1 + events.append("metadata") + } func reorderConversations(_ updates: [StoredAgentConversationOrder]) {} func truncateConversation( _ id: UUID, fromUserMessageID messageID: UUID, replacingWith message: AgentTranscriptMessage ) async throws { + truncationCount += 1 if blocksTruncation { truncationEntered = true let waiters = truncationEntryWaiters diff --git a/FrameworkTests/HermesMemoryTests.swift b/FrameworkTests/HermesMemoryTests.swift index 27c13fa6..ba021eea 100644 --- a/FrameworkTests/HermesMemoryTests.swift +++ b/FrameworkTests/HermesMemoryTests.swift @@ -113,6 +113,93 @@ struct HermesMemoryTests { await model.setEnabled(false) } + @Test func suspendedMemoryDefersTerminalHistoryIndexUntilResume() async throws { + let store = MemoryLifecycleStore() + let model = MemoryModel(storage: store) + let history = InMemoryAgentRunRepository() + let repository = MemoryIndexedAgentRepository(base: history, memory: model) + await model.setEnabled(true) + await store.waitForHistoryReplacementCount(1) + await waitForIndexingToFinish(model) + + model.suspend() + let conversationID = UUID() + try await repository.save( + .init( + conversationID: conversationID, state: .completed, permissionMode: .askForApproval, + messages: [.init(role: .user, text: "Saved after suspension")])) + + #expect(!model.isIndexing) + #expect(await store.historyReplacementCount == 1) + + model.resume() + await store.waitForHistoryReplacementCount(2) + await waitForIndexingToFinish(model) + #expect(await store.lastHistory?.contains(where: { $0.id == conversationID }) == true) + } + + @Test func suspendInvalidatesExistingMemoryAccessAcrossResume() async throws { + let store = MemoryLifecycleStore() + let model = MemoryModel(storage: store) + await model.setEnabled(true) + let conversationID = UUID() + let original = try #require(await model.session(conversationID)) + + model.suspend() + #expect(await model.session(conversationID) == nil) + model.resume() + + await #expect(throws: CancellationError.self) { try await original.access.memoryState() } + let resumed = try #require(await model.session(conversationID)) + _ = try await resumed.access.memoryState() + } + + @Test func resumeReconcilesACommittedWriteFromTheSuspendedGeneration() async throws { + let store = MemoryLifecycleStore(blocksApply: true) + let model = MemoryModel(storage: store) + await model.setEnabled(true) + let original = try #require(await model.session(UUID())) + let write = Task { + try await original.access.applyMemory([ + .init(action: .add, target: .memory, content: "Committed during suspension") + ]) + } + await store.waitUntilApplyEntered() + + model.suspend() + model.resume() + try await waitUntil { await store.memoryStateCallCount >= 2 } + await store.releaseApply() + + await #expect(throws: CancellationError.self) { try await write.value } + try await waitUntil { + await store.memoryStateCallCount >= 3 + && model.state.text(.memory) == "Committed during suspension" + } + await #expect(throws: CancellationError.self) { try await original.access.memoryState() } + } + + @Test func suspendPreventsStaleReviewFromApplyingAfterResume() async throws { + let store = MemoryLifecycleStore() + let model = MemoryModel(storage: store) + await model.setEnabled(true) + let reviewModel = BlockingMemoryReviewModel() + let review = Task { + await model.review( + conversationID: UUID(), messages: [.init(role: .user, text: "Remember concise replies")], + model: reviewModel, wait: true) + } + await reviewModel.waitUntilEntered() + + model.suspend() + model.resume() + await reviewModel.release() + await review.value + + #expect(await store.reviewApplyCount == 0) + #expect(model.state.entries.isEmpty) + } + @Test func skillPackagesPersistWithoutChangingLegacySkillRows() throws { let container = try TermindPersistence.makeContainer(inMemory: true) let persistence = SkillPersistence(context: container.mainContext) @@ -183,6 +270,133 @@ struct HermesMemoryTests { } } +private actor MemoryLifecycleStore: MemoryPersisting { + private var memory = AgentMemoryState() + private var historyReplacements: [[StoredAgentConversation]] = [] + private let blocksApply: Bool + private var applyEntered = false + private var applyReleased = false + private var historyReplacementWaiters: [Int: [CheckedContinuation]] = [:] + private var applyEntryWaiters: [CheckedContinuation] = [] + private var applyReleaseWaiters: [CheckedContinuation] = [] + private(set) var reviewApplyCount = 0 + private(set) var memoryStateCallCount = 0 + + var historyReplacementCount: Int { historyReplacements.count } + var lastHistory: [StoredAgentConversation]? { historyReplacements.last } + + init(blocksApply: Bool = false) { self.blocksApply = blocksApply } + + func waitForHistoryReplacementCount(_ count: Int) async { + guard historyReplacements.count < count else { return } + await withCheckedContinuation { historyReplacementWaiters[count, default: []].append($0) } + } + + func waitUntilApplyEntered() async { + if applyEntered { return } + await withCheckedContinuation { applyEntryWaiters.append($0) } + } + + func releaseApply() { + applyReleased = true + let waiters = applyReleaseWaiters + applyReleaseWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func rebuildHistoryIndex() {} + func memoryState() -> AgentMemoryState { + memoryStateCallCount += 1 + return memory + } + func applyMemory(_ operations: [AgentMemoryOperation]) async throws -> AgentMemoryState { + if blocksApply { + applyEntered = true + let waiters = applyEntryWaiters + applyEntryWaiters.removeAll() + for waiter in waiters { waiter.resume() } + if !applyReleased { + await withCheckedContinuation { applyReleaseWaiters.append($0) } + } + } + memory = try memory.applying(operations) + return memory + } + func snapshot(for id: UUID) -> String { memory.prompt } + func replaceHistory(_ conversations: [StoredAgentConversation]) { + historyReplacements.append(conversations) + let readyCounts = historyReplacementWaiters.keys.filter { $0 <= historyReplacements.count } + let waiters = readyCounts.flatMap { historyReplacementWaiters.removeValue(forKey: $0) ?? [] } + for waiter in waiters { waiter.resume() } + } + func searchSessions(_ request: AgentSessionSearchRequest) -> AgentJSONValue { .array([]) } + func lastReviewedMessage(_ id: UUID) -> UUID? { nil } + func markReviewed(_ messageID: UUID, conversationID: UUID) {} + func applyReview( + _ operations: [AgentMemoryOperation], expected: AgentMemoryState, messageID: UUID, conversationID: UUID + ) throws -> AgentMemoryState? { + reviewApplyCount += 1 + guard memory == expected else { return nil } + memory = try memory.applying(operations) + return memory + } + func replaceTarget(_ target: AgentMemoryTarget, text: String) -> AgentMemoryState { memory } + func close() {} + func destroy() { memory = .init() } +} + +private actor BlockingMemoryReviewModel: AgentModelCompleting { + private var entered = false + private var released = false + private var entryWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func waitUntilEntered() async { + if entered { return } + await withCheckedContinuation { entryWaiters.append($0) } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func complete(_ request: AgentModelRequest) async throws -> [AgentModelStreamEvent] { + entered = true + let waiters = entryWaiters + entryWaiters.removeAll() + for waiter in waiters { waiter.resume() } + if !released { + await withCheckedContinuation { releaseWaiters.append($0) } + } + return [ + .textDelta(#"{"operations":[{"action":"add","target":"user","content":"Likes concise replies"}]}"#), + .finished(.completed), + ] + } +} + +@MainActor +private func waitUntil( + timeout: Duration = .seconds(10), _ condition: @escaping @MainActor () async -> Bool +) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !(await condition()) { + guard clock.now < deadline else { throw MemoryLifecycleTestTimeout() } + try await Task.sleep(for: .milliseconds(1)) + } +} + +@MainActor +private func waitForIndexingToFinish(_ model: MemoryModel) async { + while model.isIndexing { await Task.yield() } +} + +private struct MemoryLifecycleTestTimeout: Error {} + private struct FailingDestroyStore: MemoryPersisting { let base: GRDBMemoryStore func rebuildHistoryIndex() async throws { try await base.rebuildHistoryIndex() } diff --git a/FrameworkTests/PortForwardingPresentationTests.swift b/FrameworkTests/PortForwardingPresentationTests.swift index 0067920f..daf401ed 100644 --- a/FrameworkTests/PortForwardingPresentationTests.swift +++ b/FrameworkTests/PortForwardingPresentationTests.swift @@ -26,14 +26,30 @@ struct PortForwardingPresentationTests { #expect(PortForwardingFieldPresentation.showsDestination(for: kind)) #expect(PortForwardingFieldPresentation.directionDescription(for: kind).contains("Destination")) #expect(!PortForwardingFieldPresentation.directionDescription(for: kind).contains("Target")) - #expect(PortForwardingFieldPresentation.directionDescription(for: kind).contains("This Mac")) - #expect(!PortForwardingFieldPresentation.directionDescription(for: kind).contains("This Device")) + #expect(PortForwardingFieldPresentation.directionDescription(for: kind).contains(localDeviceName)) + #expect(!PortForwardingFieldPresentation.directionDescription(for: kind).contains(otherDeviceName)) } @Test func dynamicForwardingUsesARequestedDestinationWithoutFixedDestinationFields() { #expect(!PortForwardingFieldPresentation.showsDestination(for: .dynamic)) #expect( PortForwardingFieldPresentation.directionDescription(for: .dynamic) - == "SOCKS5 on This Mac → SSH Host → Requested Destination") + == "SOCKS5 on \(localDeviceName) → SSH Host → Requested Destination") + } + + private var localDeviceName: String { + #if os(macOS) + "This Mac" + #else + "This Device" + #endif + } + + private var otherDeviceName: String { + #if os(macOS) + "This Device" + #else + "This Mac" + #endif } } diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 2e8d19a4..97bbb532 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -31933,4 +31933,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift index b5432284..33bb71bf 100644 --- a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift +++ b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift @@ -50,9 +50,15 @@ public final class AssistantExecutionController { } private struct PreparationState { + enum Phase { + case preparing + case savingFailure + } + var id: UUID var conversation: ConversationModel var permissionMode: AgentPermissionMode + var phase = Phase.preparing var task: Task? var historyTask: Task? var cancellationTask: Task? @@ -60,6 +66,12 @@ public final class AssistantExecutionController { var cancellationRequested = false } + private enum StartResult { + case runtimeStarted + case failurePersistenceStarted + case abandoned + } + public struct PreparedAgentEdit { fileprivate var capture: RunCapture fileprivate var replacement: AgentTranscriptMessage @@ -339,10 +351,10 @@ public final class AssistantExecutionController { private func performStartAgentRun( _ conversation: ConversationModel, capture: RunCapture, memorySession: (context: AgentMemoryContext, access: any AgentMemoryAccessing)? = nil - ) { + ) -> StartResult { guard isCurrent(capture, for: conversation), !conversation.isStreaming, !conversation.isArchived, !conversation.isHistoryChanging - else { return } + else { return .abandoned } let operationID = UUID().uuidString.lowercased() let conversationID = conversation.id.uuidString.lowercased() guard let chat = capture.chat else { @@ -352,15 +364,11 @@ public final class AssistantExecutionController { "conversation_id": "\(conversationID)", "error_code": "missing_model", "operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing", ]) - recordFailedRun( - conversation, - promptID: capture.prompt.id, prompt: capture.prompt.text, - authoredPrompt: capture.prompt.authoredText, images: capture.prompt.images, - contextAttachments: capture.prompt.contextAttachments, - permissionMode: capture.permissionMode, + beginFailedRunPersistence( + conversation, capture: capture, reason: String(localized: "No Chat model is configured.") ) - return + return .failurePersistenceStarted } guard let secret = capture.secret else { logger.error( @@ -369,15 +377,11 @@ public final class AssistantExecutionController { "conversation_id": "\(conversationID)", "error_code": "missing_credential", "operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing", ]) - recordFailedRun( - conversation, - promptID: capture.prompt.id, prompt: capture.prompt.text, - authoredPrompt: capture.prompt.authoredText, images: capture.prompt.images, - contextAttachments: capture.prompt.contextAttachments, - permissionMode: capture.permissionMode, + beginFailedRunPersistence( + conversation, capture: capture, reason: String(localized: "The Chat model has no available credential.") ) - return + return .failurePersistenceStarted } let model = AgentProviderClient( @@ -391,7 +395,7 @@ public final class AssistantExecutionController { // events had no defined order against the tool events they belong // between, and its durable writes had no defined order against // anything. See `AgentEventChannel`. - guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return } + guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return .abandoned } let skillCatalog = capture.skillCatalog let skillRegistry = AgentToolCatalog.registry( builtIn: .init( @@ -498,7 +502,9 @@ public final class AssistantExecutionController { priorMessages: conversation.pendingPriorMessages, authorizationEvidence: GuardianEvidence.collect(from: conversation.transcript), sessionContext: frozenContext.sessionContext, contextSnapshot: frozenContext - )) + ), + repository: agentRunRepository + ) logger.info( "agent.run.started", metadata: [ @@ -506,6 +512,7 @@ public final class AssistantExecutionController { "planning": "\(isPlanning)", "stage": "running", "tool_count": "\(registry.registeredTools.count)", ]) scheduleTitle(for: conversation, isPlanning: isPlanning, operationID: operationID) + return .runtimeStarted } private func canSubmit(to conversation: ConversationModel) -> Bool { @@ -572,6 +579,7 @@ public final class AssistantExecutionController { } if supersedePreparation { if let current = preparations[conversation.id] { + guard current.phase != .savingFailure else { return nil } _ = requestCancellation( for: conversation, expectedID: current.id, recordsInterruption: false) @@ -595,8 +603,12 @@ public final class AssistantExecutionController { guard isCurrent(capture, for: conversation) else { return } let needsAsync = (compactBeforeRun && capture.shouldCompact) || capture.memorySessionID != nil guard needsAsync else { - performStartAgentRun(conversation, capture: capture) - retirePreparation(capture.id, for: conversation) + switch performStartAgentRun(conversation, capture: capture) { + case .runtimeStarted, .abandoned: + retirePreparation(capture.id, for: conversation) + case .failurePersistenceStarted: + break + } return } let task = Task { [weak self, weak conversation] in @@ -618,8 +630,12 @@ public final class AssistantExecutionController { await finishCancelledPreparation(capture, for: conversation) return } - performStartAgentRun(conversation, capture: capture, memorySession: memorySession) - retirePreparation(capture.id, for: conversation) + switch performStartAgentRun(conversation, capture: capture, memorySession: memorySession) { + case .runtimeStarted, .abandoned: + retirePreparation(capture.id, for: conversation) + case .failurePersistenceStarted: + break + } } guard var state = preparations[conversation.id], state.id == capture.id else { task.cancel() @@ -688,6 +704,9 @@ public final class AssistantExecutionController { guard var state = preparations[conversation.id], expectedID == nil || state.id == expectedID else { return Task {} } + if state.phase == .savingFailure { + return state.task ?? Task {} + } state.recordsInterruption = state.recordsInterruption || recordsInterruption state.cancellationRequested = true state.task?.cancel() @@ -762,35 +781,43 @@ public final class AssistantExecutionController { retirePreparation(preparationID, for: conversation) } - private func recordFailedRun( - _ conversation: ConversationModel, - promptID: UUID, - prompt: String, - authoredPrompt: String?, - images: [AgentImageAttachment], - contextAttachments: [AgentContextAttachment], - permissionMode: AgentPermissionMode, - reason: String + private func beginFailedRunPersistence( + _ conversation: ConversationModel, capture: RunCapture, reason: String ) { + guard var state = preparations[conversation.id], state.id == capture.id else { return } var messages = conversation.pendingPriorMessages - if !messages.contains(where: { $0.id == promptID }) { + if !messages.contains(where: { $0.id == capture.prompt.id }) { messages.append( AgentTranscriptMessage( - id: promptID, role: .user, text: prompt, - authoredText: authoredPrompt, images: images, - contextAttachments: contextAttachments, - contextSnapshot: conversation.transcript.first(where: { $0.id == promptID })?.contextSnapshot + id: capture.prompt.id, role: .user, text: capture.prompt.text, + authoredText: capture.prompt.authoredText, images: capture.prompt.images, + contextAttachments: capture.prompt.contextAttachments, + contextSnapshot: capture.contextSnapshot )) } let now = Date.now let snapshot = AgentRunSnapshot( conversationID: conversation.id, state: .failed, - permissionMode: permissionMode, + permissionMode: capture.permissionMode, messages: messages, startedAt: now, finishedAt: now, failure: reason ) - conversation.recordFailedRun(snapshot) - Task { [agentRunRepository] in try? await agentRunRepository.save(snapshot) } + conversation.beginAgentPreparationFailurePersistence(id: capture.id) + state.phase = .savingFailure + let preparationID = capture.id + let task = Task { @MainActor [self, conversation, agentRunRepository] in + do { + try await agentRunRepository.save(snapshot) + guard preparations[conversation.id]?.id == preparationID else { return } + conversation.recordFailedRun(snapshot) + } catch { + guard preparations[conversation.id]?.id == preparationID else { return } + conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription)) + } + retirePreparation(preparationID, for: conversation) + } + state.task = task + preparations[conversation.id] = state } public func contextSnapshot( diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 74ea0208..027e6263 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -797,9 +797,48 @@ public final class ConversationModel: Identifiable { private var contextEstimateNeedsPublish = false private let contextEstimatePublishInterval: Duration public private(set) var contextEstimatePublishCount = 0 - private var runtime: AgentRuntime? - private var eventTask: Task? - private var runtimeGeneration: UInt64 = 0 + private final class AgentRunOwner { + enum Phase { + case queued + case starting + case started + case cancelledBeforeStart(AgentRunSnapshot) + case finished + } + + let generation: UInt64 + let runID: UUID + let startedAt: Date + let runtime: AgentRuntime + let request: AgentRunRequest + let repository: any AgentRunPersisting + var phase: Phase = .queued + var cancellationRequested = false + var prestartPersistence: Task? + var consumer: Task? + + init( + generation: UInt64, runtime: AgentRuntime, request: AgentRunRequest, + repository: any AgentRunPersisting + ) { + self.generation = generation + runID = UUID() + startedAt = .now + self.runtime = runtime + self.request = request + self.repository = repository + } + + deinit { + consumer?.cancel() + let runtime = runtime + Task { await runtime.cancel() } + } + } + + private var nextRunGeneration: UInt64 = 0 + private var activeRunOwner: AgentRunOwner? + private var runOwners: [UInt64: AgentRunOwner] = [:] private var agentPreparationID: UUID? private var cancelAgentPreparation: (() -> Task)? private var approvalWaiters: [UUID: CheckedContinuation] = [:] @@ -845,6 +884,11 @@ public final class ConversationModel: Identifiable { /// there is no runtime to accept steering until preparation completes. public private(set) var isPreparingAgentRun = false + /// True only after a captured configuration failure has committed to its + /// terminal snapshot save. Ordinary prepared edits remain supersedable, + /// while history mutations must not overtake this durable boundary. + public private(set) var isPersistingAgentPreparationFailure = false + public var isAgentRunActive: Bool { isStreaming || isPreparingAgentRun } /// The current task list, whole. Replaced rather than patched — see @@ -1042,12 +1086,16 @@ public final class ConversationModel: Identifiable { } public func canEditUserMessage(_ id: UUID) -> Bool { - guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging else { return false } + guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging, + !isPersistingAgentPreparationFailure + else { return false } return transcript.contains { $0.id == id && $0.role == .user && !$0.isCompaction } } public func canForkAssistantMessage(_ id: UUID) -> Bool { - guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging else { return false } + guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging, + !isPersistingAgentPreparationFailure + else { return false } return transcript.contains { $0.id == id && $0.role == .assistant } } @@ -1993,6 +2041,12 @@ public final class ConversationModel: Identifiable { agentPreparationID = nil cancelAgentPreparation = nil isPreparingAgentRun = false + isPersistingAgentPreparationFailure = false + } + + func beginAgentPreparationFailurePersistence(id: UUID) { + guard agentPreparationID == id else { return } + isPersistingAgentPreparationFailure = true } @discardableResult @@ -2004,36 +2058,128 @@ public final class ConversationModel: Identifiable { await cancelPendingAgentPreparation()?.value } - public func start(runtime: AgentRuntime, request: AgentRunRequest) { + public func start( + runtime: AgentRuntime, request: AgentRunRequest, + repository: any AgentRunPersisting + ) { + if let displaced = activeRunOwner { + requestCancellation(of: displaced) + stopPresentation(owner: displaced) + } activeContextSnapshot = request.contextSnapshot if let index = transcript.firstIndex(where: { $0.id == request.promptID }) { transcript[index].contextSnapshot = request.contextSnapshot contextEstimateTracker.upsert(transcript[index]) } - self.runtime = runtime reportedRunState = .running - runtimeGeneration &+= 1 - let generation = runtimeGeneration - eventTask?.cancel() - eventTask = Task { [weak self] in - guard !Task.isCancelled, self?.runtimeGeneration == generation else { return } - let events = await runtime.start(request) - guard !Task.isCancelled, self?.runtimeGeneration == generation else { - await runtime.cancel() + nextRunGeneration &+= 1 + let owner = AgentRunOwner( + generation: nextRunGeneration, runtime: runtime, request: request, + repository: repository + ) + activeRunOwner = owner + runOwners[owner.generation] = owner + let generation = owner.generation + owner.consumer = Task { [weak self, weak owner] in + guard let phase = owner?.phase else { return } + switch phase { + case .cancelledBeforeStart: + await owner?.prestartPersistence?.value + self?.finishRunOwner(generation: generation) + return + case .queued: + owner?.phase = .starting + case .starting, .started, .finished: return } - for await event in events { - guard !Task.isCancelled, let self, - self.runtimeGeneration == generation - else { - await runtime.cancel() - return - } - handle(event) + + let events = await runtime.start(request) + owner?.phase = .started + if Task.isCancelled || owner == nil || owner?.cancellationRequested == true { + await runtime.cancel() } + for await event in events { + guard let self, let owner, self.activeRunOwner === owner else { continue } + self.handle(event) + } + self?.finishRunOwner(generation: generation) } } + private func finishRunOwner(generation: UInt64) { + guard let owner = runOwners[generation] else { return } + owner.phase = .finished + owner.prestartPersistence = nil + owner.consumer = nil + runOwners[generation] = nil + if activeRunOwner === owner { activeRunOwner = nil } + } + + private func cancelledSnapshot(for owner: AgentRunOwner, finishedAt: Date) -> AgentRunSnapshot { + let request = owner.request + var messages = AgentOrphanedToolResultRepair.apply( + AgentUnansweredToolCallRepair.apply( + AgentSecretLifecycle.sanitizeHistoricalMessages(request.priorMessages) + )) + let prompt = AgentTranscriptMessage( + id: request.promptID, role: .user, text: request.prompt, + authoredText: request.authoredPrompt, images: request.promptImages, + contextAttachments: request.promptContextAttachments, + contextSnapshot: request.contextSnapshot, + createdAt: transcript.first(where: { $0.id == request.promptID })?.createdAt + ?? owner.startedAt + ) + if let index = messages.firstIndex(where: { $0.id == prompt.id }) { + if messages[index].contextSnapshot == nil { + messages[index].contextSnapshot = prompt.contextSnapshot + } + } else { + messages.append(prompt) + } + return AgentRunSnapshot( + id: owner.runID, conversationID: request.conversationID, + state: .cancelled, permissionMode: request.permissionMode, + messages: messages, startedAt: owner.startedAt, finishedAt: finishedAt + ) + } + + private func requestCancellation(of owner: AgentRunOwner) { + guard !owner.cancellationRequested else { return } + owner.cancellationRequested = true + switch owner.phase { + case .queued: + let snapshot = cancelledSnapshot(for: owner, finishedAt: .now) + owner.phase = .cancelledBeforeStart(snapshot) + let repository = owner.repository + owner.prestartPersistence = Task { + try? await repository.save(snapshot) + } + if activeRunOwner === owner { projectCancelledRun(snapshot) } + case .starting, .started: + Task { await owner.runtime.cancel() } + case .cancelledBeforeStart, .finished: + break + } + } + + private func projectCancelledRun(_ snapshot: AgentRunSnapshot) { + guard snapshot.conversationID == id, + let prompt = snapshot.messages.last(where: { $0.role == .user }) + else { return } + var summary = AgentRunSummary( + id: snapshot.id, state: .cancelled, startedAt: snapshot.startedAt, + finishedAt: snapshot.finishedAt + ) + if messageRunIDs[prompt.id] == nil + || adoptStagedReplacementRun(promptID: prompt.id, summary: &summary) + { + messageRunIDs[prompt.id] = snapshot.id + } + runSummaries[snapshot.id] = summary + reportedRunState = .cancelled + updatedAt = snapshot.finishedAt ?? .now + } + /// Records a request that could not construct a runtime, such as a missing /// model or credential, using the same durable run failure UI as a provider /// error that arrived after dispatch. @@ -2094,40 +2240,39 @@ public final class ConversationModel: Identifiable { /// Retains the ordered event consumer until the cancellation snapshot is durable. public func stopAndWait() async { - await cancelPendingAgentPreparationAndWait() - guard let runtime else { - stop() - return - } - let consumer = eventTask - let generation = runtimeGeneration - await runtime.cancel() - await consumer?.value - guard runtimeGeneration == generation else { return } - stop() + let preparation = cancelPendingAgentPreparation() + let owners = Array(runOwners.values) + let activeOwner = activeRunOwner + if let activeOwner { requestCancellation(of: activeOwner) } + stopPresentation(owner: activeOwner) + await preparation?.value + for owner in owners { await owner.consumer?.value } } public func stop() { cancelPendingAgentPreparation() - runtimeGeneration &+= 1 + let owner = activeRunOwner + if let owner { requestCancellation(of: owner) } + stopPresentation(owner: owner) + } + + private func stopPresentation(owner: AgentRunOwner?) { + let stoppedRunID = activeRunID + if let owner, activeRunOwner === owner { activeRunOwner = nil } settleStreamingPresentation(for: activeRunID) publishAllStagedTools() - for index in toolCards.indices where toolCards[index].state == .running { + if let stoppedRunID { retireStoppedRunInteractions(runID: stoppedRunID) } + for index in toolCards.indices + where toolCards[index].invocation.runID == stoppedRunID + && !toolCards[index].state.isTerminal + { toolCards[index].state = .interrupted } - eventTask?.cancel() - eventTask = nil - // Read the runtime *before* releasing it. `Task { await runtime?… }` - // resolves the property when the task runs, which is after this method - // has already set it to nil — so the run kept executing tools with - // nobody listening. - if let runtime { Task { await runtime.cancel() } } - runtime = nil if !reportedRunState.isTerminal { reportedRunState = .cancelled - // The event consumer was just cancelled, so it cannot receive the - // runtime's eventual finish event. Freeze the visible elapsed time - // here instead of letting a stopped run keep counting forever. + // Runtime terminal persistence can finish after the presentation + // stops. Freeze the visible elapsed time without cancelling its + // event drain, which is the durable join boundary. if let activeRunID, var summary = runSummaries[activeRunID] { summary.state = .cancelled summary.finishedAt = .now @@ -2138,6 +2283,32 @@ public final class ConversationModel: Identifiable { returnAllSteeringToDraft() } + private func retireStoppedRunInteractions(runID: UUID) { + let approvalIDs = pendingApprovals.compactMap { request in + request.invocation.runID == runID ? request.id : nil + } + for requestID in approvalIDs { + guard let index = pendingApprovals.firstIndex(where: { $0.id == requestID }) else { + continue + } + let request = pendingApprovals.remove(at: index) + updateToolCard(request.invocation.id) { card in + if !card.state.isTerminal { card.state = .interrupted } + } + } + + let reviewIDs = reviewing.filter { invocationID in + toolCards.first(where: { $0.id == invocationID })?.invocation.runID == runID + || stagedToolCards[invocationID]?.invocation.runID == runID + } + reviewing.subtract(reviewIDs) + for invocationID in reviewIDs { + updateToolCard(invocationID) { card in + if !card.state.isTerminal { card.state = .interrupted } + } + } + } + /// Queues the draft for the running agent instead of starting a second run. /// /// Nothing reaches `messages` or `transcript` here. A bubble posted at the @@ -2148,7 +2319,8 @@ public final class ConversationModel: Identifiable { /// really saw it. public func submitSteeringDraft(context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false) { let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard canSendDraft, let runtime else { return } + guard canSendDraft, let owner = activeRunOwner else { return } + let runtime = owner.runtime let composed = composeUserTurn(text) var snapshot = activeContextSnapshot?.withSessionContext(context.modelContext) if snapshot != nil { @@ -2170,6 +2342,7 @@ public final class ConversationModel: Identifiable { composer.draft = "" Task { [weak self] in guard await runtime.steer(pending.message) == false else { return } + guard self?.activeRunOwner === owner else { return } // The run ended between the keystroke and this hop. Everything // still queued is undeliverable, not just this one — and returning // the whole queue at once is also what keeps the words in the order @@ -2206,22 +2379,49 @@ public final class ConversationModel: Identifiable { /// moves the card's state, and enqueueing happens here — on the path that /// holds the continuation and can therefore actually answer. public func requestApproval(_ request: AgentApprovalRequest) async -> AgentApprovalDecision { - // The approval callback can arrive before the channel drains toolProposed. - // Stage the same identity now so a direct user response has a card to update. - stageTool(request.invocation, descriptor: request.descriptor) - // Inserted by when the call was *planned*, not by when it got here. A - // parallel batch races to this queue, so appending put the cards in - // whichever order the scheduler happened to resolve — the same two - // calls could be presented either way round on two runs. Planning is - // sequential, so `createdAt` is the model's own order, and asking about - // the second thing first is a needless surprise in the one place the - // user is being asked to authorize something. - let index = pendingApprovals.firstIndex { - $0.invocation.createdAt > request.invocation.createdAt + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: Self.cancelledApprovalDecision) + return + } + // The approval callback can arrive before the channel drains + // toolProposed. Stage the same identity now so a direct user + // response has a card to update. + stageTool(request.invocation, descriptor: request.descriptor) + // Inserted by when the call was planned, not by when it got + // here. Parallel calls race to this queue, while planning is + // sequential and supplies the stable user-facing order. + let index = pendingApprovals.firstIndex { + $0.invocation.createdAt > request.invocation.createdAt + } + pendingApprovals.insert(request, at: index ?? pendingApprovals.endIndex) + setToolState(request.invocation.id, .waitingForApproval) + approvalWaiters[request.id] = continuation + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancelApproval(request.id) + } } - pendingApprovals.insert(request, at: index ?? pendingApprovals.endIndex) - setToolState(request.invocation.id, .waitingForApproval) - return await withCheckedContinuation { approvalWaiters[request.id] = $0 } + } + + private static var cancelledApprovalDecision: AgentApprovalDecision { + .deny(String(localized: "The user denied this action.")) + } + + private func cancelApproval(_ requestID: UUID) { + guard let index = pendingApprovals.firstIndex(where: { $0.id == requestID }) else { + approvalWaiters.removeValue(forKey: requestID)?.resume( + returning: Self.cancelledApprovalDecision) + return + } + let request = pendingApprovals.remove(at: index) + updateToolCard(request.invocation.id) { card in + if !card.state.isTerminal { card.state = .interrupted } + } + approvalWaiters.removeValue(forKey: request.id)?.resume( + returning: Self.cancelledApprovalDecision) } public func respondToApproval(_ requestID: UUID, allow: Bool) { diff --git a/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift b/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift index c884a2a0..299d6001 100644 --- a/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift @@ -33,6 +33,7 @@ public final class MemoryModel { private var indexDirty = false private var reviews: [UUID: Task] = [:] private var pendingReviews: [UUID: (@Sendable () async -> Void)] = [:] + private var isSuspended = false public init(storage: (any MemoryPersisting)? = nil, defaults: UserDefaults? = nil) { self.storage = storage @@ -75,7 +76,7 @@ public final class MemoryModel { } catch { self.error = error.localizedDescription } } public func refresh() async { - guard isEnabled, let storage else { return } + guard isEnabled, !isSuspended, let storage else { return } let token = generation do { let value = try await storage.memoryState() @@ -86,17 +87,16 @@ public final class MemoryModel { } catch { if token == generation { self.error = error.localizedDescription } } } public func edit(_ target: AgentMemoryTarget, text: String) async { - guard isEnabled, let storage else { return } + guard isEnabled, !isSuspended, let storage else { return } let token = generation do { let updated = try await storage.replaceTarget(target, text: text) - guard token == generation, isEnabled else { return } - state = updated + try publishCommittedState(updated, token: token) error = nil } catch { if token == generation { self.error = error.localizedDescription } } } public func session(_ id: UUID) async -> (context: AgentMemoryContext, access: any AgentMemoryAccessing)? { - guard isEnabled, let storage else { return nil } + guard isEnabled, !isSuspended, let storage else { return nil } let token = generation do { let context: AgentMemoryContext @@ -116,9 +116,34 @@ public final class MemoryModel { } private func require(_ token: UUID) throws -> any MemoryPersisting { try Task.checkCancellation() - guard isEnabled, token == generation, !isBusy, let storage else { throw CancellationError() } + guard isEnabled, !isSuspended, token == generation, !isBusy, let storage else { throw CancellationError() } return storage } + private func publishCommittedState(_ updated: AgentMemoryState, token: UUID) throws { + do { + _ = try require(token) + } catch { + reloadStateForCurrentGeneration() + throw error + } + state = updated + } + private func reloadStateForCurrentGeneration() { + guard isEnabled, !isSuspended, !isBusy, let storage else { return } + let token = generation + Task { [weak self] in + guard let self else { return } + do { + let value = try await storage.memoryState() + guard token == generation, isEnabled, !isSuspended, !isBusy else { return } + state = value + error = nil + } catch { + guard token == generation, isEnabled, !isSuspended, !isBusy else { return } + self.error = error.localizedDescription + } + } + } fileprivate func read(token: UUID) async throws -> AgentMemoryState { let result = try await require(token).memoryState() _ = try require(token) @@ -127,8 +152,7 @@ public final class MemoryModel { @discardableResult fileprivate func apply(_ operations: [AgentMemoryOperation], token: UUID) async throws -> AgentMemoryState { let result = try await require(token).applyMemory(operations) - _ = try require(token) - state = result + try publishCommittedState(result, token: token) return result } fileprivate func search(_ request: AgentSessionSearchRequest, token: UUID) async throws -> AgentJSONValue { @@ -140,7 +164,7 @@ public final class MemoryModel { return result } public func rebuildHistoryIndex() async { - guard isEnabled else { return } + guard isEnabled, !isSuspended else { return } let token = generation historyChanged() await indexTask?.value @@ -151,7 +175,7 @@ public final class MemoryModel { } public func historyChanged() { - guard isEnabled, let storage, let history else { return } + guard isEnabled, !isSuspended, let storage, let history else { return } indexDirty = true guard indexTask == nil else { return } let token = generation @@ -161,7 +185,7 @@ public final class MemoryModel { defer { indexTask = nil isIndexing = false - if indexDirty, isEnabled { historyChanged() } + if indexDirty, isEnabled, !isSuspended { historyChanged() } } do { while indexDirty, token == generation, isEnabled, !Task.isCancelled { @@ -183,7 +207,7 @@ public final class MemoryModel { conversationID: UUID, messages: [AgentTranscriptMessage], model: any AgentModelCompleting, wait: Bool = false ) async { - guard isEnabled, reviewEnabled, let storage, let last = messages.last?.id else { return } + guard isEnabled, !isSuspended, reviewEnabled, let storage, let last = messages.last?.id else { return } let token = generation if let running = reviews[conversationID] { if wait { @@ -220,8 +244,7 @@ public final class MemoryModel { if let updated = try await storage.applyReview( operations, expected: current, messageID: last, conversationID: conversationID) { - _ = try require(token) - state = updated + try publishCommittedState(updated, token: token) } } catch is CancellationError { } catch { if token == generation { self.error = error.localizedDescription } } @@ -249,9 +272,15 @@ public final class MemoryModel { isBusy = false } public func suspend() { - indexTask?.cancel() - for task in reviews.values { task.cancel() } - pendingReviews.removeAll() + guard !isSuspended else { return } + isSuspended = true + invalidate() + } + public func resume() { + guard isSuspended else { return } + isSuspended = false + reloadStateForCurrentGeneration() + historyChanged() } } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index d41f4ed3..c9638a97 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -561,8 +561,10 @@ public actor AgentSSHAuthenticationContext { } private struct Pending { + var id: UUID var generation: UInt64 var task: Task + var waiters: [UUID: CheckedContinuation] } private let resolver: AgentHostResolver @@ -597,12 +599,16 @@ public actor AgentSSHAuthenticationContext { } public func configurationAttempt() async throws -> Attempt { + try Task.checkCancellation() if let cached { return makeAttempt(configuration: cached, generation: generation) } - let active: Pending + let pendingID: UUID + let pendingGeneration: UInt64 if let pending, pending.generation == generation { - active = pending + pendingID = pending.id + pendingGeneration = pending.generation } else { + let id = UUID() let currentGeneration = generation let resolver = resolver let hostID = hostID @@ -622,30 +628,33 @@ public actor AgentSSHAuthenticationContext { forcedHostID: forcedHostID, forcedProxy: forcedProxy, failure: failure ) } - active = Pending(generation: currentGeneration, task: task) - pending = active + pending = Pending( + id: id, generation: currentGeneration, task: task, waiters: [:] + ) + pendingID = id + pendingGeneration = currentGeneration + Task { [weak self] in + do { + let resolution = try await task.value + await self?.finishConfigurationAttempt( + id: id, generation: currentGeneration, result: .success(resolution) + ) + } catch { + await self?.finishConfigurationAttempt( + id: id, generation: currentGeneration, result: .failure(error) + ) + } + } } - do { - let resolution = try await active.task.value - if generation == active.generation { - submissions = resolution.submissions - proxySubmissions = resolution.proxySubmissions - temporaryHostKeys = resolution.temporaryHostKeys - cached = resolution.configuration - forcedHostID = nil - forcedProxy = false - failure = nil - if pending?.generation == active.generation { pending = nil } - } - return makeAttempt( - configuration: resolution.configuration, - generation: active.generation - ) - } catch { - if pending?.generation == active.generation { pending = nil } - throw error - } + let resolution = try await waitForConfigurationAttempt( + id: pendingID, generation: pendingGeneration + ) + try Task.checkCancellation() + return makeAttempt( + configuration: resolution.configuration, + generation: pendingGeneration + ) } /// Convenience for focused resolver tests that do not need to simulate a @@ -673,8 +682,7 @@ public actor AgentSSHAuthenticationContext { generation &+= 1 cached?.clearSecrets() cached = nil - pending?.task.cancel() - pending = nil + cancelPendingConfigurationAttempt() forcedHostID = target forcedProxy = error.isProxyAuthentication failure = error.localizedDescription @@ -686,8 +694,85 @@ public actor AgentSSHAuthenticationContext { proxySubmissions.removeAll() cached?.clearSecrets() cached = nil - pending?.task.cancel() + cancelPendingConfigurationAttempt() + } + + private func waitForConfigurationAttempt( + id: UUID, generation: UInt64 + ) async throws -> AgentHostResolver.InteractiveResolution { + let waiterID = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + guard var active = pending, + active.id == id, + active.generation == generation + else { + continuation.resume(throwing: CancellationError()) + return + } + active.waiters[waiterID] = continuation + pending = active + } + } onCancel: { + Task { [weak self] in + await self?.cancelConfigurationWaiter( + id: id, generation: generation, waiterID: waiterID + ) + } + } + } + + private func finishConfigurationAttempt( + id: UUID, generation completedGeneration: UInt64, + result: Result + ) { + guard let active = pending, + active.id == id, + active.generation == completedGeneration + else { return } pending = nil + if case .success(let resolution) = result, generation == completedGeneration { + submissions = resolution.submissions + proxySubmissions = resolution.proxySubmissions + temporaryHostKeys = resolution.temporaryHostKeys + cached = resolution.configuration + forcedHostID = nil + forcedProxy = false + failure = nil + } + for continuation in active.waiters.values { + continuation.resume(with: result) + } + } + + private func cancelConfigurationWaiter( + id: UUID, generation: UInt64, waiterID: UUID + ) { + guard var active = pending, + active.id == id, + active.generation == generation, + let continuation = active.waiters.removeValue(forKey: waiterID) + else { return } + continuation.resume(throwing: CancellationError()) + if active.waiters.isEmpty { + pending = nil + active.task.cancel() + } else { + pending = active + } + } + + private func cancelPendingConfigurationAttempt() { + guard let active = pending else { return } + pending = nil + active.task.cancel() + for continuation in active.waiters.values { + continuation.resume(throwing: CancellationError()) + } } private func makeAttempt( @@ -1077,9 +1162,19 @@ public actor AgentConnectionPool { var lastUsedAt: ContinuousClock.Instant } - private struct InFlight { + private struct ConnectionOpenKey: Hashable, Sendable { + var hostID: UUID var fingerprint: String + } + + private struct OpenedConnection: Sendable { + var value: any SSHConnectionProtocol + } + + private struct InFlight { + var id: UUID var task: Task + var waiters: [UUID: CheckedContinuation] } private let configuration: ConfigurationResolving @@ -1089,7 +1184,7 @@ public actor AgentConnectionPool { private let idleTimeout: Duration private let sweeper: AgentIdleSweeper private var entries: [UUID: Entry] = [:] - private var pending: [UUID: InFlight] = [:] + private var pending: [ConnectionOpenKey: InFlight] = [:] private var activeExecLeases: [UUID: Int] = [:] struct SFTPLease: Sendable { @@ -1449,7 +1544,7 @@ public actor AgentConnectionPool { @discardableResult public func disconnect(hostID: UUID) async -> Bool { - pending.removeValue(forKey: hostID)?.task.cancel() + cancelConnectionOpenings(hostID: hostID) let stateClosed = await disconnectSFTPState(hostID: hostID) activeExecLeases[hostID] = nil let entry = entries.removeValue(forKey: hostID) @@ -1461,7 +1556,7 @@ public actor AgentConnectionPool { public func closeAll() async { let hostIDs = Set(entries.keys) .union(sftpStates.keys) - .union(pending.keys) + .union(pending.keys.map(\.hostID)) .union(sftpPrimaryOpens.keys) for hostID in hostIDs { _ = await disconnect(hostID: hostID) } } @@ -1545,12 +1640,11 @@ public actor AgentConnectionPool { ) async throws -> any SSHConnectionProtocol { // Without this join, two tool calls that both miss the cache would each // build a connection and one of them would never be pooled — or closed. - if let inFlight = pending[hostID], inFlight.fingerprint == fingerprint { - let connection = try await inFlight.task.value - let committedFingerprint = try await routeFingerprint(hostID) - commit(connection, hostID: hostID, fingerprint: committedFingerprint) - return connection + let key = ConnectionOpenKey(hostID: hostID, fingerprint: fingerprint) + if let inFlight = pending[key] { + return try await waitForConnectionOpening(key: key, id: inFlight.id) } + let id = UUID() let task = Task { [configuration, openConnection] () async throws -> any SSHConnectionProtocol in let request: SSHConnectionRequest if let override { @@ -1562,18 +1656,122 @@ public actor AgentConnectionPool { } return try await openConnection(request) } - pending[hostID] = InFlight(fingerprint: fingerprint, task: task) - defer { if pending[hostID]?.fingerprint == fingerprint { pending[hostID] = nil } } - // Committed on every path that receives a client, not only on the path - // that started the task: whoever started it may have been cancelled - // while a second caller was still waiting on the same connection. - let connection = try await task.value - // Saving/binding an Identity while the connection prompt is open - // legitimately changes the route fingerprint. Bind the authenticated - // connection to that post-save route so the next command can reuse it. - let committedFingerprint = try await routeFingerprint(hostID) - commit(connection, hostID: hostID, fingerprint: committedFingerprint) - return connection + pending[key] = InFlight(id: id, task: task, waiters: [:]) + Task { [weak self] in + do { + let connection = try await task.value + await self?.finishConnectionOpening( + key: key, id: id, result: .success(connection) + ) + } catch { + await self?.finishConnectionOpening( + key: key, id: id, result: .failure(error) + ) + } + } + return try await waitForConnectionOpening(key: key, id: id) + } + + private func waitForConnectionOpening( + key: ConnectionOpenKey, id: UUID + ) async throws -> any SSHConnectionProtocol { + let waiterID = UUID() + let connection: OpenedConnection = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + guard var opening = pending[key], opening.id == id else { + continuation.resume(throwing: CancellationError()) + return + } + opening.waiters[waiterID] = continuation + pending[key] = opening + } + } onCancel: { + Task { [weak self] in + await self?.cancelConnectionOpeningWaiter( + key: key, id: id, waiterID: waiterID + ) + } + } + try Task.checkCancellation() + return connection.value + } + + private func finishConnectionOpening( + key: ConnectionOpenKey, id: UUID, + result initialResult: Result + ) async { + guard let opening = pending[key], opening.id == id else { + if case .success(let connection) = initialResult { + await connection.disconnect() + } + return + } + + let result: Result + switch initialResult { + case .failure(let error): + result = .failure(error) + case .success(let connection): + do { + // Saving/binding an Identity while the connection prompt is open + // legitimately changes the route fingerprint. Bind the authenticated + // connection to that post-save route so the next command can reuse it. + let committedFingerprint = try await routeFingerprint(key.hostID) + guard pending[key]?.id == id else { + await connection.disconnect() + return + } + commit( + connection, hostID: key.hostID, + fingerprint: committedFingerprint + ) + result = .success(OpenedConnection(value: connection)) + } catch { + await connection.disconnect() + result = .failure(error) + } + } + + guard let completed = pending.removeValue(forKey: key), completed.id == id else { + if case .success(let connection) = result { + await connection.value.disconnect() + } + return + } + for continuation in completed.waiters.values { + continuation.resume(with: result) + } + } + + private func cancelConnectionOpeningWaiter( + key: ConnectionOpenKey, id: UUID, waiterID: UUID + ) { + guard var opening = pending[key], opening.id == id, + let continuation = opening.waiters.removeValue(forKey: waiterID) + else { return } + continuation.resume(throwing: CancellationError()) + if opening.waiters.isEmpty { + pending[key] = nil + opening.task.cancel() + } else { + pending[key] = opening + } + } + + private func cancelConnectionOpenings(hostID: UUID) { + let keys = pending.keys.filter { $0.hostID == hostID } + for key in keys { + guard let opening = pending.removeValue(forKey: key) else { continue } + opening.task.cancel() + for continuation in opening.waiters.values { + continuation.resume(throwing: CancellationError()) + } + } } private func commit( diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index d854462e..efc4a2a6 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -122,7 +122,7 @@ final class MobileAppModel { private var scenePhases: [UUID: ScenePhase] = [:] convenience init( - hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard, + hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = nil, background: any MobileBackgroundTaskManaging = MobileBackgroundController(), workspaceConnectionServices: MobileWorkspaceConnectionServices = .live, appReview: AppReviewController = AppReviewController() @@ -558,29 +558,15 @@ final class MobileAppModel { } func sceneChanged(_ id: UUID, phase: ScenePhase) { - let wasBackground = !scenePhases.isEmpty && scenePhases.values.allSatisfy { $0 == .background } + let wasBackground = allScenesAreBackgrounded scenePhases[id] = phase - let isBackground = scenePhases.values.allSatisfy { $0 == .background } + let isBackground = allScenesAreBackgrounded if isBackground && !wasBackground { - sftpEdits.setMonitoring(false) - memory.suspend() - vaultSync?.pause() - metricsSync?.pause() - settingsSync?.pause() - metrics.pause() - outbox.stop() - assistant.suspendRecording() - background.begin { [weak self] in - guard let self else { return } - for workspace in workspaces { workspace.suspendForBackgroundExpiration() } - forwardings.stopAll() - transfers.pauseAll() - assistant.expireBackground() - } - liveActivities.update(workspaceActivitySessions) + enterBackground() } else if phase == .active { devices.updateCurrentDeviceName(Device.current.safeDescription) sftpEdits.setMonitoring(true) + memory.resume() Task { await sftpEdits.recheck() } Task { [weak self] in await self?.pro.refresh() } background.finish() @@ -648,10 +634,35 @@ final class MobileAppModel { } func removeScene(_ id: UUID) { + let wasBackground = allScenesAreBackgrounded scenePhases[id] = nil + if allScenesAreBackgrounded && !wasBackground { enterBackground() } reconcileSync() } + private var allScenesAreBackgrounded: Bool { + !scenePhases.isEmpty && scenePhases.values.allSatisfy { $0 == .background } + } + + private func enterBackground() { + sftpEdits.setMonitoring(false) + memory.suspend() + vaultSync?.pause() + metricsSync?.pause() + settingsSync?.pause() + metrics.pause() + outbox.stop() + assistant.suspendRecording() + background.begin { [weak self] in + guard let self else { return } + for workspace in workspaces { workspace.suspendForBackgroundExpiration() } + forwardings.stopAll() + transfers.pauseAll() + assistant.expireBackground() + } + liveActivities.update(workspaceActivitySessions) + } + func focusWorkspaceActivity(_ id: UUID) { liveActivities.focus(id, in: workspaceActivitySessions) } diff --git a/TFI/Application/Agent/MobileAssistantModel.swift b/TFI/Application/Agent/MobileAssistantModel.swift index d44764d4..f1d0336b 100644 --- a/TFI/Application/Agent/MobileAssistantModel.swift +++ b/TFI/Application/Agent/MobileAssistantModel.swift @@ -144,6 +144,8 @@ final class MobileAssistantModel { execution.cancelPendingRuns() speech.cancelAll() let conversations = store.conversations + // Request every cancellation before awaiting a drain that may be held by persistence. + for conversation in conversations { conversation.stop() } suspensionTask = Task { for conversation in conversations { await conversation.stopAndWait() } await background.closeAll() @@ -213,6 +215,7 @@ final class MobileAssistantModel { } func edit(_ conversation: ConversationModel, messageID: UUID, text: String) { + guard !isSuspending else { return } guard let prepared = execution.prepareAgentEdit( conversation, messageID: messageID, text: text, @@ -227,12 +230,14 @@ final class MobileAssistantModel { } func compact(_ conversation: ConversationModel, instructions: String = "") async { + guard !isSuspending else { return } _ = await execution.compact(conversation, instructions: instructions) } fileprivate func implement( _ card: AgentPlanCard, in conversation: ConversationModel, library: AssistantLibraryModel ) { + guard !isSuspending else { return } guard execution.sendPlanImplementation( card, in: conversation, diff --git a/TFI/Platform/MobileBackgroundController.swift b/TFI/Platform/MobileBackgroundController.swift index 5049685b..669bd6f2 100644 --- a/TFI/Platform/MobileBackgroundController.swift +++ b/TFI/Platform/MobileBackgroundController.swift @@ -6,37 +6,80 @@ protocol MobileBackgroundTaskManaging: AnyObject { func finish() } +@MainActor +protocol MobileBackgroundTaskPlatform: AnyObject { + func begin(expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier + func end(_ task: UIBackgroundTaskIdentifier) +} + +@MainActor +private final class UIApplicationBackgroundTaskPlatform: MobileBackgroundTaskPlatform { + func begin(expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier { + UIApplication.shared.beginBackgroundTask( + withName: "Finish remote session work", expirationHandler: expiration) + } + + func end(_ task: UIBackgroundTaskIdentifier) { + UIApplication.shared.endBackgroundTask(task) + } +} + @MainActor final class MobileBackgroundController: MobileBackgroundTaskManaging { - private var task: UIBackgroundTaskIdentifier = .invalid - private var expirationID: UUID? + private final class Lease { + var task: UIBackgroundTaskIdentifier = .invalid + var ended = false + } + + private let platform: any MobileBackgroundTaskPlatform + private var lease: Lease? + + init() { + platform = UIApplicationBackgroundTaskPlatform() + } + + init(platform: any MobileBackgroundTaskPlatform) { + self.platform = platform + } 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?.endTask() - } + let lease = Lease() + self.lease = lease + // UIKit invokes expiration synchronously on the MainActor. Request cancellation and retire the lease + // before returning; the async drains started by `expiration` remain best effort. + lease.task = platform.begin { [weak self, weak lease] in + guard let self, let lease else { return } + expire(lease, expiration: expiration) } - if task == .invalid, self.expirationID == expirationID { - self.expirationID = nil - expiration() + + guard self.lease === lease else { + retire(lease) + return + } + if lease.task == .invalid { + expire(lease, expiration: expiration) } } func finish() { - expirationID = nil - endTask() + guard let lease else { return } + self.lease = nil + retire(lease) } - private func endTask() { - guard task != .invalid else { return } - UIApplication.shared.endBackgroundTask(task) - task = .invalid + private func expire(_ lease: Lease, expiration: @MainActor () -> Void) { + guard self.lease === lease else { return } + self.lease = nil + expiration() + retire(lease) + } + + private func retire(_ lease: Lease) { + guard !lease.ended else { return } + // Expiration may run during `begin` before UIKit returns the identifier. An invalid task is still pending. + guard lease.task != .invalid else { return } + lease.ended = true + platform.end(lease.task) } } diff --git a/TFITests/MobileAdaptiveLayoutTests.swift b/TFITests/MobileAdaptiveLayoutTests.swift index 31299110..acf0eef3 100644 --- a/TFITests/MobileAdaptiveLayoutTests.swift +++ b/TFITests/MobileAdaptiveLayoutTests.swift @@ -1,3 +1,4 @@ +import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -86,7 +87,7 @@ struct MobileAdaptiveLayoutTests { try await withRoot(app: app, scene: scene, language: language, regions: regions) { controller in let composer = try #require(textInput(containing: conversation.composer.draft, in: controller.view)) let input = try #require(composer as? any UITextInput) - let transcript = try #require(find(UICollectionView.self, in: controller.view)) + let transcript = try #require(find(ListScrollView.self, in: controller.view)) #expect(composer.becomeFirstResponder()) input.setMarkedText("输入中", selectedRange: NSRange(location: 3, length: 0)) try await settle() @@ -99,7 +100,7 @@ struct MobileAdaptiveLayoutTests { regions.regions = [region] try await settle() #expect(scene.assistant.conversation === conversation) - #expect(find(UICollectionView.self, in: controller.view) === transcript) + #expect(find(ListScrollView.self, in: controller.view) === transcript) #expect(composer.isFirstResponder) #expect(composer.window != nil) let marked = try #require(input.markedTextRange) diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 29d9a4e1..bfdba839 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -411,6 +411,50 @@ struct MobileWorkspaceConnectionRecoveryTests { #expect(connections.connectCount == 1) } + @Test func removingTheLastActiveSceneStartsOneBackgroundLeaseForRemainingScenes() async throws { + let background = MobileTestBackgroundTaskManager() + let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()]) + let app = try MobileAppModel( + inMemory: true, background: background, workspaceConnectionServices: connections.services) + let host = try configureConnectionFixture(in: app) + _ = try #require(app.open(hostID: host.id, kind: .sftp)) + defer { app.closeAll() } + #expect(await eventually { app.workspaces.first?.connected == true }) + let backgroundScene = UUID() + let activeScene = UUID() + app.sceneChanged(backgroundScene, phase: .active) + app.sceneChanged(activeScene, phase: .active) + app.sceneChanged(backgroundScene, phase: .background) + + app.removeScene(activeScene) + + #expect(background.beginCount == 1) + #expect(app.workspaces.first?.connected == true) + app.removeScene(activeScene) + #expect(background.beginCount == 1) + background.expire() + #expect(app.workspaces.first?.connected == false) + + app.removeScene(backgroundScene) + #expect(background.beginCount == 1) + } + + @Test func removingABackgroundSceneWhileAnotherSceneIsVisibleDoesNotStartALease() throws { + let background = MobileTestBackgroundTaskManager() + let app = try MobileAppModel(inMemory: true, background: background) + let backgroundScene = UUID() + let activeScene = UUID() + app.sceneChanged(backgroundScene, phase: .active) + app.sceneChanged(activeScene, phase: .active) + app.sceneChanged(backgroundScene, phase: .background) + + app.removeScene(backgroundScene) + + #expect(background.beginCount == 0) + app.removeScene(activeScene) + #expect(background.beginCount == 0) + } + @Test func failedForegroundResumeKeepsReconnectionJourneyVisible() async throws { let (host, library) = try connectionFixture() let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()]) @@ -1266,8 +1310,10 @@ private final class MobileSFTPConnectionBox { @MainActor private final class MobileTestBackgroundTaskManager: MobileBackgroundTaskManaging { private var expiration: (@MainActor () -> Void)? + private(set) var beginCount = 0 func begin(expiration: @escaping @MainActor () -> Void) { + beginCount += 1 self.expiration = expiration } diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 511cce54..99e4a3d4 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -225,7 +225,7 @@ struct MobileAssistantTests { #expect(assistant.conversation.composer.draft.isEmpty) } - @Test func firstNewConversationSendPromotesDraftAndStartsRun() throws { + @Test func firstNewConversationSendPromotesDraftAndStartsRun() async throws { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let draft = assistant.conversation @@ -240,10 +240,11 @@ struct MobileAssistantTests { #expect(draft.messages.map(\.role) == [.user]) #expect(draft.messages.first?.text == "Why is the service unavailable?") #expect(draft.transcript.map(\.role) == [.user]) + #expect(await eventually { draft.runState == .failed }) try expectMissingModelFailure(in: draft) } - @Test func attachmentOnlyFirstSendPromotesTheConversation() throws { + @Test func attachmentOnlyFirstSendPromotesTheConversation() async throws { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let draft = assistant.conversation @@ -257,10 +258,11 @@ struct MobileAssistantTests { #expect(draft.messages.first?.text.isEmpty == true) #expect(draft.messages.first?.contextChips.map(\.label) == ["service.log"]) #expect(draft.transcript.first?.text.contains("connection refused") == true) + #expect(await eventually { draft.runState == .failed }) try expectMissingModelFailure(in: draft) } - @Test func planImplementationStartsAnActingTurnWithPendingComposerInputs() throws { + @Test func planImplementationStartsAnActingTurnWithPendingComposerInputs() async throws { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() @@ -284,6 +286,7 @@ struct MobileAssistantTests { #expect(turn.text.contains("")) #expect(conversation.composer.attachments.isEmpty) #expect(conversation.composer.armedSkillName == nil) + #expect(await eventually { conversation.runState == .failed }) try expectMissingModelFailure(in: conversation) } @@ -310,17 +313,20 @@ struct MobileAssistantTests { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() assistant.library.insertAndSelect(conversation) conversation.start( runtime: AgentRuntime( model: MobileDelayedAgentModel(), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "start", permissionMode: .askForApproval, contextSnapshot: .init( - toolAvailability: ["terminal_suggest": true, "future_surface_tool": false])) + toolAvailability: ["terminal_suggest": true, "future_surface_tool": false])), + repository: repository ) conversation.composer.draft = "Inspect again" @@ -479,11 +485,158 @@ struct MobileAssistantTests { #expect(app.assistant.isSuspending) assistant.send() #expect(assistant.conversation.composer.draft == "preserve") + await assistant.compact(assistant.conversation) + #expect(assistant.conversation.compactionActivity == nil) await app.assistant.waitForSuspension() #expect(!app.assistant.isSuspending) #expect(!app.assistant.isRunning) } + @Test func backgroundExpirationBlocksEditsUntilCleanupCompletesThenAllowsRecovery() async throws { + let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() + let conversation = assistant.conversation + conversation.composer.draft = "Original question" + assistant.send() + #expect(await eventually { conversation.runState == .failed }) + let original = try #require(conversation.messages.first { $0.role == .user }) + let blocker = ConversationModel() + assistant.library.insertAndSelect(blocker) + let blockerRepository = MobileTerminalSaveGateRepository() + blocker.start( + runtime: AgentRuntime( + model: MobileImmediateAssistantModel(calls: MobileAssistantModelCalls()), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: blockerRepository + ), + request: AgentRunRequest( + conversationID: blocker.id, prompt: "block cleanup", permissionMode: .askForApproval), + repository: blockerRepository + ) + await blockerRepository.waitForTerminalSave() + + app.assistant.expireBackground() + #expect(app.assistant.isSuspending) + assistant.edit(conversation, messageID: original.id, text: "Replacement question") + await Task.yield() + #expect(conversation.messages.first(where: { $0.role == .user })?.text == "Original question") + + await blockerRepository.releaseTerminalSaves() + await app.assistant.waitForSuspension() + #expect(!app.assistant.isSuspending) + assistant.edit(conversation, messageID: original.id, text: "Replacement question") + #expect( + await eventually { + conversation.messages.first(where: { $0.role == .user })?.text == "Replacement question" + } + ) + #expect(await eventually { conversation.runState == .failed }) + } + + @Test func backgroundExpirationBlocksPlanImplementationUntilCleanupCompletesThenAllowsRecovery() async throws { + 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") + conversation.isPlanning = true + conversation.handle(.planPresented(plan)) + let card = try #require(conversation.plans.first) + let blocker = ConversationModel() + assistant.library.insertAndSelect(blocker) + let blockerRepository = MobileTerminalSaveGateRepository() + blocker.start( + runtime: AgentRuntime( + model: MobileImmediateAssistantModel(calls: MobileAssistantModelCalls()), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: blockerRepository + ), + request: AgentRunRequest( + conversationID: blocker.id, prompt: "block cleanup", permissionMode: .askForApproval), + repository: blockerRepository + ) + await blockerRepository.waitForTerminalSave() + + app.assistant.expireBackground() + #expect(app.assistant.isSuspending) + assistant.implement(card, in: conversation) + #expect(conversation.plans.first?.outcome == .pending) + #expect(conversation.transcript.isEmpty) + + await blockerRepository.releaseTerminalSaves() + await app.assistant.waitForSuspension() + #expect(!app.assistant.isSuspending) + assistant.implement(card, in: conversation) + #expect(conversation.plans.first?.outcome == .implemented) + let implementation = try #require(conversation.transcript.last) + #expect(implementation.role == .user) + #expect(implementation.contextSnapshot?.planContract == nil) + #expect(await eventually { conversation.runState == .failed }) + } + + @Test func backgroundExpirationStopsEveryConversationBeforeJoiningTheFirstDrain() async throws { + let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() + let first = ConversationModel() + let second = ConversationModel() + assistant.library.insertAndSelect(second) + assistant.library.insertAndSelect(first) + assistant.select(second) + #expect(app.assistant.store.conversations.map(\.id) == [first.id, second.id]) + let secondCalls = MobileAssistantModelCalls() + let firstRepository = MobileTerminalSaveGateRepository() + let secondRepository = InMemoryAgentRunRepository() + first.start( + runtime: AgentRuntime( + model: MobileImmediateAssistantModel(calls: MobileAssistantModelCalls()), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: firstRepository + ), + request: AgentRunRequest( + conversationID: first.id, prompt: "first", permissionMode: .askForApproval), + repository: firstRepository + ) + await firstRepository.waitForTerminalSave() + second.start( + runtime: AgentRuntime( + model: MobileImmediateAssistantModel(calls: secondCalls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: secondRepository + ), + request: AgentRunRequest( + conversationID: second.id, prompt: "second", permissionMode: .askForApproval), + repository: secondRepository + ) + + app.assistant.expireBackground() + app.assistant.expireBackground() + + #expect(app.assistant.isSuspending) + #expect(!first.isStreaming) + #expect(first.runState.isTerminal) + #expect(second.runState == .cancelled) + second.composer.draft = "preserve" + assistant.send() + #expect(second.composer.draft == "preserve") + #expect(secondCalls.count == 0) + let join = MobileAssistantJoinProbe() + let waiter = Task { @MainActor in + join.markStarted() + await app.assistant.waitForSuspension() + join.markFinished() + } + await join.waitUntilStarted() + #expect(!join.finished) + #expect(app.assistant.isSuspending) + + await firstRepository.releaseTerminalSaves() + await waiter.value + #expect(!app.assistant.isSuspending) + #expect(secondCalls.count == 0) + } + private func measuredInteractionHeight( conversation: ConversationModel, hosts: HostLibraryModel, @@ -518,6 +671,89 @@ private struct MobileDelayedAgentModel: AgentModelStreaming { } } +nonisolated private final class MobileAssistantModelCalls: @unchecked Sendable { + private let lock = NSLock() + private var callCount = 0 + + func called() { + lock.withLock { callCount += 1 } + } + + var count: Int { lock.withLock { callCount } } +} + +@MainActor +private final class MobileAssistantJoinProbe { + private(set) var finished = false + private var started = false + private var startWaiters: [CheckedContinuation] = [] + + func markStarted() { + started = true + let waiters = startWaiters + startWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func waitUntilStarted() async { + guard !started else { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func markFinished() { finished = true } +} + +private struct MobileImmediateAssistantModel: AgentModelStreaming { + let calls: MobileAssistantModelCalls + + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + calls.called() + continuation.yield(.finished(.completed)) + continuation.finish() + } + } +} + +private actor MobileTerminalSaveGateRepository: AgentRunPersisting { + private var terminalSaveEntered = false + private var terminalSaveWaiters: [CheckedContinuation] = [] + private var terminalReleaseWaiters: [CheckedContinuation] = [] + + func save(_ snapshot: AgentRunSnapshot) async { + guard snapshot.state.isTerminal else { return } + terminalSaveEntered = true + let waiters = terminalSaveWaiters + terminalSaveWaiters.removeAll() + for waiter in waiters { waiter.resume() } + await withCheckedContinuation { terminalReleaseWaiters.append($0) } + } + + func waitForTerminalSave() async { + guard !terminalSaveEntered else { return } + await withCheckedContinuation { terminalSaveWaiters.append($0) } + } + + func releaseTerminalSaves() { + let waiters = terminalReleaseWaiters + terminalReleaseWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func record(_ event: AgentEvent, runID: UUID) {} + func persistCompaction(_ record: AgentCompactionRecord, conversationID: UUID) {} + func loadConversations() -> [StoredAgentConversation] { [] } + func deleteConversation(_ id: UUID) {} + func setTitle(_ title: String, conversationID: UUID) {} + func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {} + func reorderConversations(_ updates: [StoredAgentConversationOrder]) {} + func truncateConversation( + _ id: UUID, fromUserMessageID messageID: UUID, + replacingWith message: AgentTranscriptMessage + ) {} + func createConversation(_ conversation: StoredAgentConversation) {} +} + @MainActor private func expectMissingModelFailure(in conversation: ConversationModel) throws { // In-memory apps have no Chat model. That must produce a failure row, not an authored answer. diff --git a/TFITests/MobileBackgroundControllerTests.swift b/TFITests/MobileBackgroundControllerTests.swift new file mode 100644 index 00000000..fe4a8de7 --- /dev/null +++ b/TFITests/MobileBackgroundControllerTests.swift @@ -0,0 +1,95 @@ +import Testing +import UIKit + +@testable import TermindIOS + +@Suite @MainActor +struct MobileBackgroundControllerTests { + @Test func expirationRunsSynchronouslyAndEndsEachLeaseExactlyOnce() throws { + let first = UIBackgroundTaskIdentifier(rawValue: 1) + let second = UIBackgroundTaskIdentifier(rawValue: 2) + let platform = MobileBackgroundTaskPlatformSpy(tasks: [first, second]) + var events: [String] = [] + platform.didEnd = { _ in events.append("end") } + let controller = MobileBackgroundController(platform: platform) + + controller.begin { events.append("expire-first") } + let stale = try #require(platform.expirations.first) + controller.finish() + stale() + stale() + + controller.begin { events.append("expire-second") } + let current = try #require(platform.expirations.last) + current() + current() + controller.finish() + + #expect(events == ["end", "expire-second", "end"]) + #expect(platform.ended == [first, second]) + } + + @Test func synchronousExpirationDuringBeginRetiresReturnedTaskWithoutOverwritingReplacement() throws { + let expired = UIBackgroundTaskIdentifier(rawValue: 1) + let replacement = UIBackgroundTaskIdentifier(rawValue: 2) + let platform = MobileBackgroundTaskPlatformSpy(tasks: [expired, replacement]) + var expirationCount = 0 + var replacementExpirationCount = 0 + var controller: MobileBackgroundController! + platform.expireSynchronouslyOnNextBegin = true + controller = MobileBackgroundController(platform: platform) + + controller.begin { + expirationCount += 1 + controller.begin { replacementExpirationCount += 1 } + } + + #expect(expirationCount == 1) + #expect(replacementExpirationCount == 0) + #expect(platform.ended == [expired]) + let replacementExpiration = try #require(platform.expirations.last) + replacementExpiration() + #expect(replacementExpirationCount == 1) + #expect(platform.ended == [expired, replacement]) + } + + @Test func invalidBeginExpiresOnceWithoutEndingAnInvalidTask() { + var expirationCount = 0 + let platform = MobileBackgroundTaskPlatformSpy(tasks: [.invalid]) + let controller = MobileBackgroundController(platform: platform) + + controller.begin { expirationCount += 1 } + controller.finish() + + #expect(expirationCount == 1) + #expect(platform.ended.isEmpty) + } +} + +@MainActor +private final class MobileBackgroundTaskPlatformSpy: MobileBackgroundTaskPlatform { + var expirations: [@MainActor () -> Void] = [] + var ended: [UIBackgroundTaskIdentifier] = [] + var expireSynchronouslyOnNextBegin = false + var didEnd: ((UIBackgroundTaskIdentifier) -> Void)? + private var tasks: [UIBackgroundTaskIdentifier] + + init(tasks: [UIBackgroundTaskIdentifier]) { + self.tasks = tasks + } + + func begin(expiration: @escaping @MainActor () -> Void) -> UIBackgroundTaskIdentifier { + let task = tasks.removeFirst() + expirations.append(expiration) + if expireSynchronouslyOnNextBegin { + expireSynchronouslyOnNextBegin = false + expiration() + } + return task + } + + func end(_ task: UIBackgroundTaskIdentifier) { + ended.append(task) + didEnd?(task) + } +} diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 69a37603..e565c102 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -537,7 +537,7 @@ struct MobileCommandGeneratorTests { } } -@Suite @MainActor +@Suite(.serialized) @MainActor struct MobileUploadBatchTests { @Test func usernameOnlyTerminalUploadRejectsInteractionWithoutOpeningASheet() async throws { let identity = Identity(name: "OTP", username: "user") diff --git a/TFITests/MobileEditorToolbarTests.swift b/TFITests/MobileEditorToolbarTests.swift index 76611776..a277831b 100644 --- a/TFITests/MobileEditorToolbarTests.swift +++ b/TFITests/MobileEditorToolbarTests.swift @@ -5,7 +5,12 @@ import UIKit @testable import TermindIOS -@Suite(.serialized) @MainActor +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", + "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), + .serialized) +@MainActor struct MobileEditorToolbarTests { @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func saveDisablesActionsUntilCompletionAndThenDismisses(language: String) async throws { diff --git a/TFITests/MobileForwardingRenderingTests.swift b/TFITests/MobileForwardingRenderingTests.swift index 2f6df162..dc140082 100644 --- a/TFITests/MobileForwardingRenderingTests.swift +++ b/TFITests/MobileForwardingRenderingTests.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI import TermindApplication import TermindDomain @@ -6,7 +7,12 @@ import UIKit @testable import TermindIOS -@Suite(.serialized) @MainActor +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", + "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), + .serialized) +@MainActor struct MobileForwardingRenderingTests { @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func forwardingUsesVaultCardsAndInPageAuthenticationWithoutASheet(_ language: String) async throws { diff --git a/TFITests/MobileHostIntegrationRenderingTests.swift b/TFITests/MobileHostIntegrationRenderingTests.swift index d2f18344..267ecb6e 100644 --- a/TFITests/MobileHostIntegrationRenderingTests.swift +++ b/TFITests/MobileHostIntegrationRenderingTests.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI import TermindApplication import TermindDomain @@ -7,7 +8,12 @@ import UIKit @testable import TermindIOS -@Suite(.serialized) @MainActor +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", + "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), + .serialized) +@MainActor struct MobileHostIntegrationRenderingTests { @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func integrationPickerKeepsItsDraftAtPhoneWidth(language: String) async throws { diff --git a/TFITests/MobileSavedNameRenderingTests.swift b/TFITests/MobileSavedNameRenderingTests.swift index 307a6213..00527db4 100644 --- a/TFITests/MobileSavedNameRenderingTests.swift +++ b/TFITests/MobileSavedNameRenderingTests.swift @@ -1,4 +1,5 @@ import AgentKit +import Foundation import SwiftUI import TermindApplication import TermindDomain @@ -8,7 +9,12 @@ import UIKit @testable import TermindIOS -@Suite(.serialized) @MainActor +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", + "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), + .serialized) +@MainActor struct MobileSavedNameRenderingTests { @Test func forwardingNameHasNoPlaceholderUntilAHostIsSelected() async throws { let draft = ForwardingNameDraft() diff --git a/TFM/App/AppDependencies.swift b/TFM/App/AppDependencies.swift index c86dac26..22dba657 100644 --- a/TFM/App/AppDependencies.swift +++ b/TFM/App/AppDependencies.swift @@ -7,6 +7,14 @@ import TermindRemoteInfrastructure import struct TermindDomain.Host +@MainActor +struct AppShutdownDependencies { + let stopMCP: @MainActor () async -> Void + let stopSFTP: @MainActor () async -> Void + let closeAgentBackgroundCommands: @MainActor () async -> Void + let closeAgentConnections: @MainActor () async -> Void +} + @MainActor struct AppDependencies { let hostRepository: any HostRepository @@ -21,6 +29,8 @@ struct AppDependencies { let mcpServerSecrets: any SecretStoring let logging: any ApplicationLogManaging let settingsDefaults: UserDefaults + let assistantConversations: AssistantConversationStore? + let shutdown: AppShutdownDependencies? init( hostRepository: any HostRepository, outboxRunner: OutboxRunner?, @@ -33,7 +43,9 @@ struct AppDependencies { metricsPreferences: (any MetricsConfigurationStoring)? = nil, mcpServerSecrets: any SecretStoring = InMemorySecretStore(), logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared, - settingsDefaults: UserDefaults = .standard + settingsDefaults: UserDefaults = .standard, + assistantConversations: AssistantConversationStore? = nil, + shutdown: AppShutdownDependencies? = nil ) { self.hostRepository = hostRepository self.outboxRunner = outboxRunner @@ -47,6 +59,8 @@ struct AppDependencies { self.mcpServerSecrets = mcpServerSecrets self.logging = logging self.settingsDefaults = settingsDefaults + self.assistantConversations = assistantConversations + self.shutdown = shutdown } static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self { diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 04bdbd7c..e8ed3ac0 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -42,6 +42,7 @@ nonisolated enum HostOpenTarget: Hashable, Sendable { @Observable final class AppModel { private var terminationTask: Task? + private var isTerminating = false private final class WeakWindow { weak var value: WindowModel? init(_ value: WindowModel) { self.value = value } @@ -104,7 +105,7 @@ final class AppModel { let speechTranscription: SpeechTranscriptionCoordinator /// The one set of conversations every window navigates. See /// `AssistantConversationStore`. - private let assistantConversations = AssistantConversationStore() + private let assistantConversations: AssistantConversationStore private let agentRunRepository: any AgentRunPersisting private let agentHosts: AgentHostResolver private let agentConnections: AgentConnectionPool @@ -115,6 +116,7 @@ final class AppModel { private let agentBackgroundCommands: AgentBackgroundCommands private let outboxRunner: OutboxRunner? private let settingsDefaults: UserDefaults + private let shutdownDependencies: AppShutdownDependencies? private var windows: [ObjectIdentifier: WeakWindow] = [:] private var deletedAgentConversationIDs = Set() /// Conversations already handed to the summarizer. Titling is once per @@ -162,6 +164,8 @@ final class AppModel { self.windowSizeStore = windowSizeStore let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups) settingsDefaults = dependencies.settingsDefaults + assistantConversations = dependencies.assistantConversations ?? AssistantConversationStore() + shutdownDependencies = dependencies.shutdown logging = dependencies.logging let externalAuthentication = ExternalAuthCoordinator( logger: logging.makeLogger(label: "termind.external-mcp")) @@ -393,7 +397,10 @@ final class AppModel { @ObservationIgnored private lazy var assistantExecution = AssistantExecutionController( ai: ai, skills: skills, repository: agentRunRepository, memory: memory, - authorize: { [weak self] in self?.proAccess.require(.assistant) == true }, + authorize: { [weak self] in + guard let self, !self.isTerminating else { return false } + return self.proAccess.require(.assistant) + }, makeRegistry: { [weak self] conversation, model, provider, skills, channel in guard let self else { return AgentToolRegistry([AnyAgentTool]()) } return AssistantToolRegistry.make( @@ -717,9 +724,47 @@ final class AppModel { /// The delegate gives this asynchronous cleanup a bounded termination window. @discardableResult func shutdownForTermination() -> Task { + isTerminating = true + if let terminationTask { return terminationTask } + let conversations = assistantConversations.conversations + let preparationCancellation = assistantExecution.cancelPendingRuns() + for conversation in conversations { conversation.stop() } + let conversationDrains = conversations.map { conversation in + Task { @MainActor in await conversation.stopAndWait() } + } + let task = Task { + [shutdownDependencies, mcpServer, sftpTransfers, agentBackgroundCommands, agentConnections] in + async let stopMCP: Void = { + if let stop = shutdownDependencies?.stopMCP { + await stop() + } else { + await mcpServer.stop() + } + }() + async let stopSFTP: Void = { + if let stop = shutdownDependencies?.stopSFTP { + await stop() + } else { + await sftpTransfers.waitForCancellation() + } + }() + await preparationCancellation.value + for drain in conversationDrains { await drain.value } + _ = await (stopMCP, stopSFTP) + if let close = shutdownDependencies?.closeAgentBackgroundCommands { + await close() + } else { + await agentBackgroundCommands.closeAll() + } + if let close = shutdownDependencies?.closeAgentConnections { + await close() + } else { + await agentConnections.closeAll() + } + } + terminationTask = task memory.suspend() sftpEdits.setMonitoring(false) - if let terminationTask { return terminationTask } serverMetrics.shutdown() speechTranscription.cancelAll() let liveWindows = windows.values.compactMap(\.value) @@ -728,14 +773,6 @@ final class AppModel { sftpTransfers.cancelAll() externalAuthentication.cancelAll() forwarding.stopAll() - let task = Task { [mcpServer, sftpTransfers, agentConnections, agentBackgroundCommands] in - async let stopMCP: Void = mcpServer.stop() - async let stopSFTP: Void = sftpTransfers.waitForCancellation() - _ = await (stopMCP, stopSFTP) - await agentBackgroundCommands.closeAll() - await agentConnections.closeAll() - } - terminationTask = task outboxRunner?.stop() return task } diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 14290784..b2410a2c 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -213,6 +213,34 @@ struct TermindApp: App { } #if canImport(AppKit) + @MainActor + final class AppTerminationReplyCoordinator { + typealias Sleep = @Sendable (Duration) async throws -> Void + + private var replied = false + + func begin( + cleanup: Task, timeout: Duration = .seconds(3), + sleep: @escaping Sleep = { try await Task.sleep(for: $0) }, + reply: @escaping @MainActor () -> Void + ) { + Task { @MainActor [weak self] in + await cleanup.value + self?.finish(reply) + } + Task { @MainActor [weak self] in + try? await sleep(timeout) + self?.finish(reply) + } + } + + private func finish(_ reply: @MainActor () -> Void) { + guard !replied else { return } + replied = true + reply() + } + } + /// Owns native application lifecycle; window and pane commands live in `TermindCommands`. final class AppDelegate: NSObject, NSApplicationDelegate { weak var app: AppModel? { @@ -228,7 +256,7 @@ struct TermindApp: App { private let metricsActivity = MetricsActivityObserver() private let metricsMenu = MetricsMenuBarController() private var awaitingTermination = false - private var repliedToTermination = false + private let terminationReply = AppTerminationReplyCoordinator() private var quietLaunchObserver: (any NSObjectProtocol)? private let appearanceController = AppAppearanceController() @@ -381,20 +409,9 @@ struct TermindApp: App { private func beginTermination(_ app: AppModel) { app.mainWindows.saveSizeForTermination() let cleanup = app.shutdownForTermination() - Task { @MainActor [weak self] in - await cleanup.value - self?.finishTermination() + terminationReply.begin(cleanup: cleanup) { + NSApp.reply(toApplicationShouldTerminate: true) } - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(3)) - self?.finishTermination() - } - } - - private func finishTermination() { - guard !repliedToTermination else { return } - repliedToTermination = true - NSApp.reply(toApplicationShouldTerminate: true) } func applicationWillTerminate(_ notification: Notification) { diff --git a/TFM/Application/MCPServer/MCPUploadCoordinator.swift b/TFM/Application/MCPServer/MCPUploadCoordinator.swift index fdc222ab..a3307a80 100644 --- a/TFM/Application/MCPServer/MCPUploadCoordinator.swift +++ b/TFM/Application/MCPServer/MCPUploadCoordinator.swift @@ -177,13 +177,20 @@ final class MCPUploadCoordinator { runID: ticket.info.runID, clientName: ticket.info.clientName, hostID: ticket.request.hostID, remotePath: ticket.request.remotePath, overwrite: ticket.request.overwrite ) + let cancellation = ticket.info.cancellation + // The transfer cancels its own token on any failure, so track HTTP disconnect provenance separately. + let disconnection = TransferCancellationToken() tickets[id] = ticket return .accept( { [weak self] read in guard let self else { return Self.error(503, "upload_unavailable") } - return await self.receive(id: id, attempt: attempt, read: read) + return await self.receive(id: id, attempt: attempt, disconnection: disconnection, read: read) }, - disconnected: { [weak self] in Task { @MainActor in self?.interrupt(id, attempt: attempt) } } + disconnected: { [weak self] in + disconnection.cancel() + cancellation.cancel() + Task { @MainActor in self?.interrupt(id, attempt: attempt) } + } ) } @@ -220,9 +227,10 @@ final class MCPUploadCoordinator { tickets.removeAll() } - private func receive(id: UUID, attempt: UUID, read: @escaping SFTPStreamUploadOperation.ReadChunk) async - -> HTTPResponse - { + private func receive( + id: UUID, attempt: UUID, disconnection: TransferCancellationToken, + read: @escaping SFTPStreamUploadOperation.ReadChunk + ) async -> HTTPResponse { guard let ticket = tickets[id], ticket.attemptID == attempt, ticket.info.state == "connecting", !ticket.info.cancellation.isCancelled else { @@ -244,8 +252,14 @@ final class MCPUploadCoordinator { return Self.json(200, payload(completed)) } catch { let cleanup = error as? SFTPTemporaryFileCleanupError + let cancelRequested = tickets[id]?.info.cancelRequested == true + let interrupted = + tickets[id]?.interrupted == true + || disconnection.isCancelled + if interrupted { tickets[id]?.interrupted = true } let (status, code) = Self.failure( - cleanup?.cause ?? error, cancelled: tickets[id]?.info.cancelRequested == true) + cleanup?.cause ?? error, + cancelled: cancelRequested || disconnection.isCancelled) tickets[id]?.temporaryPath = cleanup?.path if let failure = (cleanup?.cause ?? error) as? SFTPStreamUploadOperation.Failure, case .digestMismatch(let actual) = failure @@ -253,9 +267,9 @@ final class MCPUploadCoordinator { tickets[id]?.actualSHA256 = actual } let canResume = - tickets[id]?.info.cancelRequested != true + !cancelRequested && !Self.integrityFailure(cleanup?.cause ?? error) - && (tickets[id]?.interrupted == true || Self.recoverable(cleanup?.cause ?? error)) + && (interrupted || Self.recoverable(cleanup?.cause ?? error)) && ticket.info.expiresAt > now() if !canResume { await cleanupTicket(id) } finish( diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index c757322f..a7c293eb 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -336,8 +336,8 @@ struct WorkspaceNavigatorView: View { private func beginWorkspaceRename(_ workspace: Workspace) { guard workspace.isRenameable else { return } - renamingWorkspaceID = workspace.id workspaceRenameTitle = workspace.title(in: app.hostLibrary, locale: locale) + renamingWorkspaceID = workspace.id } private func commitWorkspaceRename(_ workspace: Workspace) { diff --git a/TFMTests/AgentApprovalQueueTests.swift b/TFMTests/AgentApprovalQueueTests.swift index c701d50d..46e8d979 100644 --- a/TFMTests/AgentApprovalQueueTests.swift +++ b/TFMTests/AgentApprovalQueueTests.swift @@ -135,6 +135,7 @@ struct AgentApprovalQueueTests { func twoApprovalsInOneTurnQueueAndAreBothAnswered() async { let log = CallLog() let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() let runtime = AgentRuntime( model: QueueScriptedModel(turnsCalling(["first", "second"])), tools: [gatedTool("first", log: log), gatedTool("second", log: log)], @@ -144,14 +145,16 @@ struct AgentApprovalQueueTests { guard let conversation else { return .deny("gone") } return await conversation.requestApproval(request) } - ) + ), + repository: repository ) conversation.start( runtime: runtime, request: AgentRunRequest( conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval - )) + ), + repository: repository) await wait { conversation.pendingApprovals.count == 2 } // Both are waiting, but only the head is presented: authorizing three @@ -184,6 +187,7 @@ struct AgentApprovalQueueTests { func denyingTheHeadLeavesTheNextRequestAnswerable() async { let log = CallLog() let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() let runtime = AgentRuntime( model: QueueScriptedModel(turnsCalling(["denied", "allowed"])), tools: [gatedTool("denied", log: log), gatedTool("allowed", log: log)], @@ -193,14 +197,16 @@ struct AgentApprovalQueueTests { guard let conversation else { return .deny("gone") } return await conversation.requestApproval(request) } - ) + ), + repository: repository ) conversation.start( runtime: runtime, request: AgentRunRequest( conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval - )) + ), + repository: repository) await wait { conversation.pendingApprovals.count == 2 } guard let firstID = conversation.pendingApproval?.id else { diff --git a/TFMTests/AgentConnectionPoolTests.swift b/TFMTests/AgentConnectionPoolTests.swift index fc4fc68e..3e6f2215 100644 --- a/TFMTests/AgentConnectionPoolTests.swift +++ b/TFMTests/AgentConnectionPoolTests.swift @@ -78,6 +78,63 @@ struct AgentConnectionPoolTests { #expect(await backend.execOpens == 1) } + @Test + func cancellingTheOnlyConnectionWaiterDoesNotWaitForTheSharedOpen() async throws { + let backend = StubBackend() + let gate = Gate() + let pool = makePool(backend, openerGate: gate) + let host = UUID() + let completion = ConnectionCompletionProbe() + let opening = Task { + defer { Task { await completion.finish() } } + return try await pool.connection(hostID: host) + } + while await gate.arrivals == 0 { await Task.yield() } + + opening.cancel() + let completedBeforeOpenFinished = await waitForCompletion(completion) + await gate.open() + + #expect(completedBeforeOpenFinished) + await #expect(throws: CancellationError.self) { + _ = try await opening.value + } + for _ in 0..<1_000 where await backend.execDisconnects == 0 { + await Task.yield() + } + #expect(await backend.execDisconnects == 1) + #expect(await pool.openConnectionCount == 0) + } + + @Test + func cancellingOneConnectionWaiterPreservesTheSharedOpenForAnotherWaiter() async throws { + let backend = StubBackend() + let gate = Gate() + let pool = makePool(backend, openerGate: gate) + let host = UUID() + let firstCompletion = ConnectionCompletionProbe() + let first = Task { + defer { Task { await firstCompletion.finish() } } + return try await pool.connection(hostID: host) + } + while await gate.arrivals == 0 { await Task.yield() } + let second = Task { try await pool.connection(hostID: host) } + for _ in 0..<100 { await Task.yield() } + + first.cancel() + let firstCompletedBeforeOpenFinished = await waitForCompletion(firstCompletion) + await gate.open() + + #expect(firstCompletedBeforeOpenFinished) + await #expect(throws: CancellationError.self) { + _ = try await first.value + } + #expect(try serial(await second.value) == 1) + #expect(await backend.execOpens == 1) + #expect(await backend.execDisconnects == 0) + #expect(await pool.openConnectionCount == 1) + } + @Test func execAndSFTPShareOneConnectionAndCloseTogether() async throws { let backend = StubBackend() @@ -285,6 +342,20 @@ struct AgentConnectionPoolTests { } } +private func waitForCompletion(_ completion: ConnectionCompletionProbe) async -> Bool { + for _ in 0..<1_000 { + if await completion.isFinished { return true } + await Task.yield() + } + return await completion.isFinished +} + +private actor ConnectionCompletionProbe { + private(set) var isFinished = false + + func finish() { isFinished = true } +} + /// Which stub connection the pool handed back. Distinguishing "reused" from /// "reconnected" is the whole subject of these tests. private func serial(_ connection: any SSHConnectionProtocol) throws -> Int { diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index 00db0256..b58fc3c4 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -64,6 +64,75 @@ struct AgentHostAuthenticationTests { #expect(configuration.credentials.isEmpty) } + @Test + func cancellingTheOnlyConfigurationWaiterClearsItsAuthenticationCard() async throws { + let host = Host(name: "Server", host: "server.example") + let store = HostLibraryModel(hosts: [host]) + let conversation = ConversationModel() + let session = AgentSSHAuthenticationContext( + resolver: AgentHostResolver(store: store), hostID: host.id, + invocationID: UUID(), interaction: WeakAgentUserInteraction(conversation).sshInteraction + ) + let completion = AuthenticationCompletionProbe() + let attempt = Task { + defer { Task { await completion.finish() } } + return try await session.configurationAttempt() + } + try await waitForAuthentication(in: conversation) + + attempt.cancel() + let completedBeforeAnswer = await waitForCompletion(completion) + let cardCleared = conversation.pendingSSHAuthentication == nil + if !cardCleared { + conversation.respondToSSHAuthentication(.cancelled) + } + + #expect(completedBeforeAnswer) + #expect(cardCleared) + await #expect(throws: CancellationError.self) { + _ = try await attempt.value + } + } + + @Test + func cancellingOneSharedConfigurationWaiterKeepsTheOtherOwnerRunning() async throws { + let host = Host(name: "Server", host: "server.example") + let store = HostLibraryModel(hosts: [host]) + let conversation = ConversationModel() + let session = AgentSSHAuthenticationContext( + resolver: AgentHostResolver(store: store), hostID: host.id, + invocationID: UUID(), interaction: WeakAgentUserInteraction(conversation).sshInteraction + ) + let firstCompletion = AuthenticationCompletionProbe() + let first = Task { + defer { Task { await firstCompletion.finish() } } + return try await session.configurationAttempt() + } + try await waitForAuthentication(in: conversation) + let second = Task { try await session.configurationAttempt() } + for _ in 0..<100 { await Task.yield() } + + first.cancel() + let firstCompletedBeforeAnswer = await waitForCompletion(firstCompletion) + let request = try #require(conversation.pendingSSHAuthentication) + conversation.respondToSSHAuthentication( + .submit( + AgentSSHAuthenticationAnswer( + username: "deploy", password: "one-use", save: false + )), + requestID: request.id + ) + + #expect(firstCompletedBeforeAnswer) + await #expect(throws: CancellationError.self) { + _ = try await first.value + } + let retained = try await second.value + #expect(retained.configuration.username == "deploy") + #expect(password(in: retained.configuration, matches: "one-use")) + #expect(conversation.pendingSSHAuthentication == nil) + } + @Test func concurrentConnectionChallengesAreNeverCoalescedWithSavedCredentials() async throws { let identity = Identity(name: "OTP", username: "deploy") let host = Host(name: "Server", host: "server.example", identityID: identity.id) @@ -1177,6 +1246,30 @@ struct AgentHostAuthenticationTests { } return false } + + private func waitForAuthentication(in conversation: ConversationModel) async throws { + for _ in 0..<1_000 { + if conversation.pendingSSHAuthentication != nil { return } + await Task.yield() + } + throw AuthenticationCancellationTestError.timedOut + } + + private func waitForCompletion(_ completion: AuthenticationCompletionProbe) async -> Bool { + for _ in 0..<1_000 { + if await completion.isFinished { return true } + await Task.yield() + } + return await completion.isFinished + } +} + +private enum AuthenticationCancellationTestError: Error { case timedOut } + +private actor AuthenticationCompletionProbe { + private(set) var isFinished = false + + func finish() { isFinished = true } } private final class MemoryHostSecretStore: SecretStoring, @unchecked Sendable { diff --git a/TFMTests/AppTerminationTests.swift b/TFMTests/AppTerminationTests.swift new file mode 100644 index 00000000..f9a00cbe --- /dev/null +++ b/TFMTests/AppTerminationTests.swift @@ -0,0 +1,355 @@ +import AgentKit +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import Testing + +@testable import Termind + +@Suite(.timeLimit(.minutes(1))) @MainActor +struct AppTerminationTests { + @Test + func shutdownCancelsEveryStoredConversationBeforeAwaitingAndClosesResourcesAfterDrains() async throws { + let events = TerminationEventLog() + let firstRepository = TerminationRepository( + blocksTerminalSave: true, + terminalSaved: { await events.append("save") }) + let secondRepository = TerminationRepository() + let firstCalls = TerminationModelCalls() + let secondCalls = TerminationModelCalls() + let first = queuedConversation(repository: firstRepository, calls: firstCalls) + let second = queuedConversation(repository: secondRepository, calls: secondCalls) + let store = AssistantConversationStore(conversations: [first, second]) + let app = makeApp( + repository: firstRepository, store: store, + shutdown: AppShutdownDependencies( + stopMCP: { await events.append("mcp") }, + stopSFTP: { await events.append("sftp") }, + closeAgentBackgroundCommands: { await events.append("background") }, + closeAgentConnections: { await events.append("connections") } + )) + let window = WindowModel() + app.registerWindow(window) + #expect(window.assistantLibrary.conversation(id: first.id) === first) + window.openAssistantInSidebar(second.id) + #expect(window.sidebar.assistantConversationID == second.id) + app.unregisterWindow(window) + + let cleanup = app.shutdownForTermination() + let repeatedCleanup = app.shutdownForTermination() + await firstRepository.waitForTerminalSave() + + #expect(first.runState == .cancelled) + #expect(second.runState == .cancelled) + #expect(await firstCalls.count == 0) + #expect(await secondCalls.count == 0) + #expect(!(await events.values).contains("background")) + #expect(!(await events.values).contains("connections")) + + await firstRepository.releaseTerminalSaves() + await cleanup.value + await repeatedCleanup.value + + let values = await events.values + let save = try #require(values.firstIndex(of: "save")) + let background = try #require(values.firstIndex(of: "background")) + let connections = try #require(values.firstIndex(of: "connections")) + #expect(values.filter { $0 == "mcp" }.count == 1) + #expect(values.filter { $0 == "sftp" }.count == 1) + #expect(values.filter { $0 == "background" }.count == 1) + #expect(values.filter { $0 == "connections" }.count == 1) + #expect(save < background) + #expect(background < connections) + #expect(await firstRepository.savedTerminalState == .cancelled) + #expect(await secondRepository.savedTerminalState == .cancelled) + } + + @Test + func shutdownFencesEveryAssistantAdmissionAndInvalidatesAnAlreadyPreparedEdit() async throws { + let repository = TerminationRepository() + let original = AgentTranscriptMessage(role: .user, text: "before", authoredText: "before") + let preparedEdit = ConversationModel( + messages: [ChatMessage(id: original.id, role: .user, text: original.text)], + transcript: [original]) + let draft = ConversationModel() + draft.composer.draft = "draft after shutdown" + let normal = ConversationModel() + let directStart = ConversationModel() + let planConversation = ConversationModel() + let plan = AgentPlan( + title: "Shutdown plan", body: "Must not run", scratchPath: "/tmp/shutdown-plan.md") + planConversation.isPlanning = true + planConversation.handle(.planPresented(plan)) + let postFenceEditMessage = AgentTranscriptMessage(role: .user, text: "edit me", authoredText: "edit me") + let postFenceEdit = ConversationModel( + messages: [ChatMessage(id: postFenceEditMessage.id, role: .user, text: postFenceEditMessage.text)], + transcript: [postFenceEditMessage]) + let store = AssistantConversationStore( + conversations: [preparedEdit, draft, normal, directStart, planConversation, postFenceEdit]) + let app = makeApp(repository: repository, store: store) + let window = WindowModel() + app.registerWindow(window) + + window.assistantCommands?.edit( + preparedEdit.id, original.id, "prepared before shutdown", .none, false) + #expect(preparedEdit.isPreparingAgentRun) + let cleanup = app.shutdownForTermination() + + app.sendAgentDraft(draft) + let newConversation = ConversationModel() + newConversation.composer.draft = "new conversation after shutdown" + #expect(app.sendNewAgentConversation(newConversation, in: window.assistantLibrary) == nil) + window.assistantLibrary.draftConversation.composer.draft = "new after shutdown" + #expect(app.sendNewAgentDraft(in: window.assistantLibrary) == nil) + #expect(!app.sendAgentText("normal after shutdown", in: normal)) + + let planCard = try #require(planConversation.plans.first) + #expect(!app.sendPlanImplementation(planCard, in: planConversation)) + + app.startAgentRun( + directStart, prompt: "explicit start after shutdown", + authoredPrompt: "explicit start after shutdown") + window.assistantCommands?.edit( + postFenceEdit.id, postFenceEditMessage.id, "edit after shutdown", .none, false) + + await cleanup.value + for _ in 0..<10 { await Task.yield() } + + #expect(preparedEdit.transcript == [original]) + #expect(draft.transcript.isEmpty) + #expect(draft.composer.draft == "draft after shutdown") + #expect(normal.transcript.isEmpty) + #expect(directStart.transcript.isEmpty) + #expect(planConversation.plans.first?.outcome == .pending) + #expect(planConversation.isPlanning) + #expect(postFenceEdit.transcript == [postFenceEditMessage]) + #expect(newConversation.transcript.isEmpty) + #expect(await repository.truncationCount == 0) + #expect(await repository.savedSnapshots.map(\.state) == [.interrupted]) + } + + @Test + func shutdownJoinsAnUnstoredCapturedFailureBeforeClosingResources() async { + let events = TerminationEventLog() + let repository = TerminationRepository( + blocksTerminalSave: true, + terminalSaved: { await events.append("save") }) + let app = makeApp( + repository: repository, + shutdown: AppShutdownDependencies( + stopMCP: {}, stopSFTP: {}, + closeAgentBackgroundCommands: { await events.append("background") }, + closeAgentConnections: { await events.append("connections") })) + let conversation = ConversationModel() + + #expect(app.sendAgentText("captured failure", in: conversation)) + await repository.waitForTerminalSave() + #expect(conversation.isPersistingAgentPreparationFailure) + + let cleanup = app.shutdownForTermination() + for _ in 0..<10 { await Task.yield() } + #expect(!(await events.values).contains("background")) + #expect(await repository.savedSnapshots.map(\.state) == [.failed]) + + await repository.releaseTerminalSaves() + await cleanup.value + + #expect(conversation.runState == .failed) + #expect(await repository.savedSnapshots.map(\.state) == [.failed]) + #expect(await events.values == ["save", "background", "connections"]) + } + + @Test + func terminationReplyFallsBackWhenCleanupHangsAndRepliesOnlyOnce() async { + let cleanupGate = TerminationGate() + let fallbackGate = TerminationGate() + let replies = TerminationReplyCount() + let cleanup = Task { await cleanupGate.wait() } + let coordinator = AppTerminationReplyCoordinator() + + coordinator.begin( + cleanup: cleanup, timeout: .seconds(3), + sleep: { duration in + #expect(duration == .seconds(3)) + await fallbackGate.wait() + }, + reply: { replies.value += 1 }) + + await fallbackGate.waitUntilEntered() + #expect(replies.value == 0) + await fallbackGate.release() + await waitUntil { replies.value == 1 } + + await cleanupGate.release() + await cleanup.value + for _ in 0..<10 { await Task.yield() } + #expect(replies.value == 1) + + let lateFallback = TerminationGate() + let cleanupFirstReplies = TerminationReplyCount() + let cleanupFirst = AppTerminationReplyCoordinator() + cleanupFirst.begin( + cleanup: Task {}, + sleep: { _ in await lateFallback.wait() }, + reply: { cleanupFirstReplies.value += 1 }) + await waitUntil { cleanupFirstReplies.value == 1 } + await lateFallback.release() + for _ in 0..<10 { await Task.yield() } + #expect(cleanupFirstReplies.value == 1) + } + + private func queuedConversation( + repository: any AgentRunPersisting, calls: TerminationModelCalls + ) -> ConversationModel { + let conversation = ConversationModel() + let runtime = AgentRuntime( + model: TerminationCountingModel(calls: calls), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .deny("test") }), + repository: repository) + conversation.start( + runtime: runtime, + request: AgentRunRequest( + conversationID: conversation.id, prompt: "queued", + permissionMode: .askForApproval), + repository: repository) + return conversation + } + + private func makeApp( + repository: any AgentRunPersisting, + store: AssistantConversationStore = AssistantConversationStore(), + shutdown: AppShutdownDependencies? = nil + ) -> AppModel { + let defaults = UserDefaults(suiteName: "termind.tests.termination.\(UUID().uuidString)")! + return AppModel( + hosts: HostLibraryModel(), + dependencies: AppDependencies( + hostRepository: InMemoryHostRepository(), + outboxRunner: nil, + agentRunRepository: repository, + settingsDefaults: defaults, + assistantConversations: store, + shutdown: shutdown)) + } + + private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async { + while !condition() { await Task.yield() } + } +} + +private struct TerminationCountingModel: AgentModelStreaming { + let calls: TerminationModelCalls + + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + await calls.called() + continuation.yield(.finished(.completed)) + continuation.finish() + } + } + } +} + +private actor TerminationModelCalls { + private(set) var count = 0 + func called() { count += 1 } +} + +private actor TerminationEventLog { + private(set) var values: [String] = [] + func append(_ value: String) { values.append(value) } +} + +private actor TerminationGate { + private var entered = false + private var released = false + private var entryWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func wait() async { + entered = true + let waitingForEntry = entryWaiters + entryWaiters.removeAll() + for waiter in waitingForEntry { waiter.resume() } + guard !released else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilEntered() async { + guard !entered else { return } + await withCheckedContinuation { entryWaiters.append($0) } + } + + func release() { + released = true + let waitingForRelease = releaseWaiters + releaseWaiters.removeAll() + for waiter in waitingForRelease { waiter.resume() } + } +} + +@MainActor +private final class TerminationReplyCount { + var value = 0 +} + +private actor TerminationRepository: AgentRunPersisting { + private let blocksTerminalSave: Bool + private let terminalSaved: (@Sendable () async -> Void)? + private var terminalSaveEntered = false + private var terminalSaveWaiters: [CheckedContinuation] = [] + private var terminalReleaseWaiters: [CheckedContinuation] = [] + private(set) var savedTerminalState: AgentRunState? + private(set) var savedSnapshots: [AgentRunSnapshot] = [] + private(set) var truncationCount = 0 + + init( + blocksTerminalSave: Bool = false, + terminalSaved: (@Sendable () async -> Void)? = nil + ) { + self.blocksTerminalSave = blocksTerminalSave + self.terminalSaved = terminalSaved + } + + func save(_ snapshot: AgentRunSnapshot) async { + savedSnapshots.append(snapshot) + guard snapshot.state.isTerminal else { return } + terminalSaveEntered = true + let waiters = terminalSaveWaiters + terminalSaveWaiters.removeAll() + for waiter in waiters { waiter.resume() } + if blocksTerminalSave { + await withCheckedContinuation { terminalReleaseWaiters.append($0) } + } + await terminalSaved?() + savedTerminalState = snapshot.state + } + + func waitForTerminalSave() async { + guard !terminalSaveEntered else { return } + await withCheckedContinuation { terminalSaveWaiters.append($0) } + } + + func releaseTerminalSaves() { + let waiters = terminalReleaseWaiters + terminalReleaseWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func record(_ event: AgentEvent, runID: UUID) {} + func persistCompaction(_ record: AgentCompactionRecord, conversationID: UUID) {} + func loadConversations() -> [StoredAgentConversation] { [] } + func deleteConversation(_ id: UUID) {} + func setTitle(_ title: String, conversationID: UUID) {} + func setConversationMetadata(_ metadata: StoredAgentConversationMetadata, conversationID: UUID) {} + func reorderConversations(_ updates: [StoredAgentConversationOrder]) {} + func truncateConversation( + _ id: UUID, fromUserMessageID messageID: UUID, + replacingWith message: AgentTranscriptMessage + ) { + truncationCount += 1 + } + func createConversation(_ conversation: StoredAgentConversation) {} +} diff --git a/TFMTests/ChatComposerStateTests.swift b/TFMTests/ChatComposerStateTests.swift index d4bb3185..bc6b46d9 100644 --- a/TFMTests/ChatComposerStateTests.swift +++ b/TFMTests/ChatComposerStateTests.swift @@ -769,6 +769,7 @@ struct ChatComposerStateTests { @Test func immediatelyCompletedRuntimeStillReachesTheConversation() async { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() let runtime = AgentRuntime( model: ImmediateConversationAgentModel(turns: [ [ @@ -777,7 +778,8 @@ struct ChatComposerStateTests { ] ]), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ) conversation.start( @@ -785,7 +787,8 @@ struct ChatComposerStateTests { request: AgentRunRequest( conversationID: conversation.id, prompt: "hello", permissionMode: .askForApproval - )) + ), + repository: repository) await waitForTerminal(conversation) #expect(conversation.runState == .completed) @@ -831,9 +834,11 @@ struct ChatComposerStateTests { return AgentToolResult(callID: invocation.call.id, content: "host-a") } let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() let runtime = AgentRuntime( model: model, tools: [tool], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ) conversation.start( @@ -841,7 +846,8 @@ struct ChatComposerStateTests { request: AgentRunRequest( conversationID: conversation.id, prompt: "inspect", permissionMode: .askForApproval - )) + ), + repository: repository) await waitForTerminal(conversation) #expect(await executions.value == 1) @@ -883,6 +889,7 @@ struct ChatComposerStateTests { AgentToolResult(callID: invocation.call.id, content: "host-a") } let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() conversation.appendUserTurn("inspect") // The prompt travels on the request, so it must not also be replayed. #expect(conversation.pendingPriorMessages.isEmpty) @@ -890,12 +897,14 @@ struct ChatComposerStateTests { conversation.start( runtime: AgentRuntime( model: model, tools: [tool], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "inspect", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) @@ -932,6 +941,7 @@ struct ChatComposerStateTests { return AgentToolResult(callID: invocation.call.id, content: "never") } let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() conversation.appendUserTurn("wait for me") conversation.start( runtime: AgentRuntime( @@ -942,12 +952,14 @@ struct ChatComposerStateTests { ] ]), tools: [tool], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "wait for me", permissionMode: .askForApproval - ) + ), + repository: repository ) await probe.waitUntilStarted() #expect(await probe.started) @@ -970,6 +982,7 @@ struct ChatComposerStateTests { @Test func timelineInterleavesToolCardsWithTheMessagesTheyBelongTo() async { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() conversation.appendUserTurn("inspect") conversation.start( runtime: AgentRuntime( @@ -995,12 +1008,14 @@ struct ChatComposerStateTests { AgentToolResult(callID: invocation.call.id, content: "host-a") } ], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "inspect", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) @@ -1312,6 +1327,7 @@ struct ChatComposerStateTests { @Test func finishedReasoningOnlyAnswerLeavesNoWorkedHistory() async { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() conversation.appendUserTurn("compare") conversation.start( runtime: AgentRuntime( @@ -1323,12 +1339,14 @@ struct ChatComposerStateTests { ] ]), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "compare", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) @@ -1435,6 +1453,7 @@ struct ChatComposerStateTests { @Test func finishedReasoningDisappearsWhileToolsMergeAcrossProviderTurns() async throws { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() conversation.appendUserTurn("inspect both") conversation.start( runtime: AgentRuntime( @@ -1470,12 +1489,14 @@ struct ChatComposerStateTests { AgentToolResult(callID: invocation.call.id, content: "ok") } ], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "inspect both", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) @@ -1501,6 +1522,7 @@ struct ChatComposerStateTests { @Test func providerTurnsWithoutReasoningShareOneRunScopedToolBatch() async throws { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() conversation.appendUserTurn("inspect four") conversation.start( runtime: AgentRuntime( @@ -1531,12 +1553,14 @@ struct ChatComposerStateTests { AgentToolResult(callID: invocation.call.id, content: "ok") } ], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "inspect four", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) @@ -1898,6 +1922,7 @@ struct ChatComposerStateTests { weak var leaked: ConversationModel? do { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() leaked = conversation conversation.appendUserTurn("hello") conversation.start( @@ -1915,12 +1940,14 @@ struct ChatComposerStateTests { return await conversation.requestApproval(request) } ), + repository: repository, userInteraction: WeakAgentUserInteraction(conversation) ), request: AgentRunRequest( conversationID: conversation.id, prompt: "hello", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) } @@ -2023,6 +2050,7 @@ struct ChatComposerStateTests { @Test func aFinishedRunNotifiesItsOwnerExactlyOnce() async { let conversation = ConversationModel() + let repository = InMemoryAgentRunRepository() var finishes = 0 conversation.runDidFinish = { finishes += 1 } conversation.appendUserTurn("hello") @@ -2034,12 +2062,14 @@ struct ChatComposerStateTests { ] ]), tools: [], - approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), + repository: repository ), request: AgentRunRequest( conversationID: conversation.id, prompt: "hello", permissionMode: .askForApproval - ) + ), + repository: repository ) await waitForTerminal(conversation) await wait { finishes > 0 } diff --git a/TFMTests/MCPUploadTests.swift b/TFMTests/MCPUploadTests.swift index f6ce6a12..4264ee69 100644 --- a/TFMTests/MCPUploadTests.swift +++ b/TFMTests/MCPUploadTests.swift @@ -359,10 +359,7 @@ struct MCPUploadTests { return Data([1]) } } - for _ in 0..<100 { - if await fixture.client.started { break } - await Task.yield() - } + #expect(await waitUntil { await fixture.client.started }) switch reason { case "disabled": fixture.uploads.setEnabled(false) case "session": await fixture.uploads.endSession(runID: fixture.runID) @@ -375,6 +372,19 @@ struct MCPUploadTests { #expect(await fixture.client.committed == nil) #expect(!fixture.uploads.hasActiveUpload(runID: fixture.runID)) #expect(fixture.history.entries(tool: "http_sftp_upload").first?.status == .error) + if reason == "disconnect" { + #expect( + try fixture.uploads.status(id: fixture.id(ticket), runID: fixture.runID).objectValue?["status"] + == .string("paused")) + } + } + + private func waitUntil(_ condition: @escaping () async -> Bool) async -> Bool { + for _ in 0..<200 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return false } @Test(arguments: [true, false]) diff --git a/TFMTests/StoreKitTrialTests.swift b/TFMTests/StoreKitTrialTests.swift index 9568b3a3..70892e85 100644 --- a/TFMTests/StoreKitTrialTests.swift +++ b/TFMTests/StoreKitTrialTests.swift @@ -11,7 +11,11 @@ import Testing @testable import Termind -@Suite(.serialized) +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_STOREKIT_TESTS"] == "1", + "Set TERMIND_STOREKIT_TESTS=1 to run StoreKit tests that require manual authorization"), + .serialized) @MainActor struct StoreKitTrialTests { @Test diff --git a/TFMTests/WorkspaceNavigatorRenameTests.swift b/TFMTests/WorkspaceNavigatorRenameTests.swift new file mode 100644 index 00000000..d0196015 --- /dev/null +++ b/TFMTests/WorkspaceNavigatorRenameTests.swift @@ -0,0 +1,131 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import Testing + +@testable import Termind + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct WorkspaceNavigatorRenameTests { + @Test func contextMenuRenameStartsWithTheCompleteWorkspaceTitle() async throws { + let fixture = WorkspaceNavigatorRenameFixture() + defer { fixture.close() } + try await fixture.prepare() + + let (_, menu) = try fixture.workspaceMenu() + let renameIndex = try #require(menu.items.firstIndex { $0.title == "Rename" }) + menu.performActionForItem(at: renameIndex) + + try await fixture.expectCompleteTitle() + } + + @Test func doubleClickRenameStartsWithTheCompleteWorkspaceTitle() async throws { + let fixture = WorkspaceNavigatorRenameFixture() + defer { fixture.close() } + try await fixture.prepare() + + let (menuView, _) = try fixture.workspaceMenu() + fixture.doubleClick(menuView) + + try await fixture.expectCompleteTitle() + } +} + +@MainActor +private final class WorkspaceNavigatorRenameFixture { + private let title = "Production Workspace" + private let app: AppModel + private let controller: NSHostingController + private let window: NSWindow + + init() { + let host = TermindDomain.Host(name: title, host: "workspace.invalid") + app = AppModel(hosts: HostLibraryModel(hosts: [host])) + let navigation = WindowModel(workspaces: [.sshSession(host: host)]) + controller = NSHostingController( + rootView: AnyView( + WorkspaceNavigatorView() + .environment(app) + .environment(navigation) + .environment(\.locale, Locale(identifier: "en")) + .frame(width: 280, height: 640) + )) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 280, height: 640), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + controller.sizingOptions = [] + window.contentViewController = controller + controller.view.frame = window.contentView?.bounds ?? .zero + } + + func prepare() async throws { + window.orderFrontRegardless() + try await Task.sleep(for: .milliseconds(250)) + controller.view.layoutSubtreeIfNeeded() + } + + func workspaceMenu() throws -> (NativeRowContextMenu.RowMenuView, NSMenu) { + for menuView in descendants(of: controller.view).compactMap({ $0 as? NativeRowContextMenu.RowMenuView }) { + guard let menu = menuView.makeMenu() else { continue } + let titles = Set(menu.items.map(\.title)) + if titles.contains("Save Workspace Template"), titles.contains("Rename") { + return (menuView, menu) + } + } + Issue.record("Workspace row context menu was not found") + throw WorkspaceNavigatorRenameFixtureError.missingWorkspaceMenu + } + + func doubleClick(_ view: NSView) { + let location = view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil) + sendMouseEvent(.leftMouseDown, at: location, clickCount: 1, timestamp: 1) + sendMouseEvent(.leftMouseUp, at: location, clickCount: 1, timestamp: 1.05) + sendMouseEvent(.leftMouseDown, at: location, clickCount: 2, timestamp: 1.1) + sendMouseEvent(.leftMouseUp, at: location, clickCount: 2, timestamp: 1.15) + } + + func expectCompleteTitle() async throws { + try await Task.sleep(for: .milliseconds(150)) + controller.view.layoutSubtreeIfNeeded() + // SidebarInlineRenameField selects its initial value when it receives focus. The + // Workspace regression was constructing that field before this full title arrived. + _ = try #require( + descendants(of: controller.view).compactMap { $0 as? NSTextField } + .first { $0.isEditable && $0.stringValue == title }) + } + + func close() { + app.shutdownForTermination() + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + + private func sendMouseEvent( + _ type: NSEvent.EventType, + at location: NSPoint, + clickCount: Int, + timestamp: TimeInterval + ) { + guard + let event = NSEvent.mouseEvent( + with: type, location: location, modifierFlags: [], timestamp: timestamp, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, + clickCount: clickCount, pressure: type == .leftMouseDown ? 1 : 0) + else { + Issue.record("Could not create a synthetic mouse event") + return + } + window.sendEvent(event) + } + + private func descendants(of view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(descendants) + } +} + +private enum WorkspaceNavigatorRenameFixtureError: Error { + case missingWorkspaceMenu +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 920e831e..78752370 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -17,8 +17,6 @@ 10DD764492A94453449B3484 /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; 1D479CE3B3FF74B0C23BA3F2 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 20ADB83C9DB7F78EDF8BCBDF /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; - DEVK00000000000000000003 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; - DEVK00000000000000000004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; 23E272F32F769A979F3ABF42 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 25C0658E2DD9B2FE28023DA7 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; 26C689ECB8B41A357765CCF1 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; @@ -76,6 +74,8 @@ CE6200000000000000000001 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; CF863393BB8C2B38A9EEE18A /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; D7D1BBB65020F61F7EFDDFD7 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100012 /* GhosttyTerminal */; }; + DEVK00000000000000000003 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; + DEVK00000000000000000004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; E635C54CA9F1A63D909CFCE6 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; EF2F88E4809C845C3F1BBF63 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; F04700000000000000000001 /* TFMTests/SSHForwardingPumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */; }; @@ -2769,14 +2769,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/devicekit/DeviceKit.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.8.0; - }; - }; BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/groue/GRDB.swift.git"; @@ -2785,6 +2777,14 @@ version = 7.11.1; }; }; + DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/devicekit/DeviceKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.8.0; + }; + }; LOGG00000000000000000001 /* XCRemoteSwiftPackageReference "swift-log" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-log.git"; @@ -2860,16 +2860,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - DEVK00000000000000000002 /* DeviceKit */ = { - isa = XCSwiftPackageProductDependency; - package = DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */; - productName = DeviceKit; - }; BAAD00000000000000000002 /* GRDB */ = { isa = XCSwiftPackageProductDependency; package = BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */; productName = GRDB; }; + DEVK00000000000000000002 /* DeviceKit */ = { + isa = XCSwiftPackageProductDependency; + package = DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */; + productName = DeviceKit; + }; LOGG00000000000000000002 /* Logging */ = { isa = XCSwiftPackageProductDependency; package = LOGG00000000000000000001 /* XCRemoteSwiftPackageReference "swift-log" */;