Merge parallel lifecycle fixes

# Conflicts:
#	FrameworkTests/AgentReplayPersistenceTests.swift
#	FrameworkTests/AgentSteeringTests.swift
#	FrameworkTests/AssistantExecutionControllerTests.swift
#	Frameworks/TermindApplication/Agent/AssistantExecutionController.swift
#	Frameworks/TermindApplication/Agent/ConversationModel.swift
#	TFI/Application/Agent/MobileAssistantModel.swift
#	TFITests/MobileAssistantTests.swift
#	TFM/App/AppModel.swift
#	openwiki/.claims/architecture/desktop-ui.json
#	openwiki/.claims/architecture/engineering-invariants.json
#	openwiki/.claims/architecture/mobile-ui.json
#	openwiki/.claims/architecture/module-map.json
#	openwiki/.claims/architecture/native-app-directories.json
#	openwiki/.claims/architecture/platform-runtime.json
#	openwiki/.claims/architecture/shared-module-directories.json
#	openwiki/.claims/architecture/supporting-system-directories.json
#	openwiki/.claims/architecture/system-boundaries.json
#	openwiki/.claims/concepts/agent-runtime-and-assistant.json
#	openwiki/.claims/concepts/commerce-localization-and-product-surfaces.json
#	openwiki/.claims/concepts/persistence-and-secrets.json
#	openwiki/.claims/concepts/remote-services-and-forwarding.json
#	openwiki/.claims/concepts/ssh-multiplexing-and-scheduling.json
#	openwiki/.claims/concepts/ui-design-principles.json
#	openwiki/.claims/integrations/mcp-server-and-bridge.json
#	openwiki/.claims/operations/build-test-and-release.json
#	openwiki/.claims/operations/cloudkit-release-readiness.json
#	openwiki/.claims/operations/storefront-commerce-release.json
#	openwiki/.claims/quickstart.json
#	openwiki/.claims/testing/behavioral-boundaries-and-fixtures.json
#	openwiki/.claims/workflows/settings-and-cloud-sync.json
#	openwiki/.claims/workflows/ssh-session-lifecycle.json
#	openwiki/.last-update.json
#	openwiki/.page-manifest.json
#	openwiki/architecture/desktop-ui.md
#	openwiki/architecture/engineering-invariants.md
#	openwiki/architecture/mobile-ui.md
#	openwiki/architecture/module-map.md
#	openwiki/architecture/native-app-directories.md
#	openwiki/architecture/platform-runtime.md
#	openwiki/architecture/shared-module-directories.md
#	openwiki/architecture/supporting-system-directories.md
#	openwiki/architecture/system-boundaries.md
#	openwiki/concepts/agent-runtime-and-assistant.md
#	openwiki/concepts/commerce-localization-and-product-surfaces.md
#	openwiki/concepts/persistence-and-secrets.md
#	openwiki/concepts/remote-services-and-forwarding.md
#	openwiki/concepts/ssh-multiplexing-and-scheduling.md
#	openwiki/concepts/ui-design-principles.md
#	openwiki/integrations/mcp-server-and-bridge.md
#	openwiki/operations/build-test-and-release.md
#	openwiki/operations/cloudkit-release-readiness.md
#	openwiki/operations/storefront-commerce-release.md
#	openwiki/quickstart.md
#	openwiki/testing/behavioral-boundaries-and-fixtures.md
#	openwiki/workflows/settings-and-cloud-sync.md
#	openwiki/workflows/ssh-session-lifecycle.md
This commit is contained in:
Kaede Akino committed 2026-09-23 18:33:57 +08:00
commit 68c137dafc
38 files changed
+3067 -323

No files matched your search

+4
View File
@@ -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/<Suite>` 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/<Suite>` 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.
@@ -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<PersistentAgentRun>())
#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<PersistentAgentRun>())
#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<AgentModelStreamEvent, any Error> {
AsyncThrowingStream { continuation in
Task {
await calls.called()
continuation.yield(.finished(.completed))
continuation.finish()
}
}
}
}
+10 -4
View File
@@ -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
)
}
}
@@ -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<Void, Never>] = []
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<Void, Never>] = []
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<Void, Never>] = []
private var terminalReleaseWaiters: [CheckedContinuation<Void, Never>] = []
private var terminalSavedWaiters: [CheckedContinuation<Void, Never>] = []
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<AgentModelStreamEvent, any Error> {
@@ -325,3 +658,33 @@ private struct CountingAssistantModel: AgentModelStreaming {
}
}
}
private struct HangingCountingAssistantModel: AgentModelStreaming {
let calls: AssistantModelCalls
func stream(_ request: AgentModelRequest) -> AsyncThrowingStream<AgentModelStreamEvent, any Error> {
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<AgentModelStreamEvent, any Error> {
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() }
}
@@ -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<Value: AnyObject> {
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<Void, Never>] = []
private var saveReleaseWaiters: [CheckedContinuation<Void, Never>] = []
private var saveCompletionWaiters: [CheckedContinuation<Void, Never>] = []
private var snapshotWaiters: [(state: AgentRunState, continuation: CheckedContinuation<Void, Never>)] = []
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<Void, Never>)] = []
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
+214
View File
@@ -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<Void, Never>]] = [:]
private var applyEntryWaiters: [CheckedContinuation<Void, Never>] = []
private var applyReleaseWaiters: [CheckedContinuation<Void, Never>] = []
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<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
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() }
@@ -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
}
}
+1 -1
View File
@@ -31933,4 +31933,4 @@
}
},
"version" : "1.2"
}
}
@@ -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<Void, Never>?
var historyTask: Task<ConversationModel.ComposedUserTurn?, any Error>?
var cancellationTask: Task<Void, Never>?
@@ -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(
@@ -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<Void, Never>?
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<Void, Never>?
var consumer: Task<Void, Never>?
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<Void, Never>)?
private var approvalWaiters: [UUID: CheckedContinuation<AgentApprovalDecision, Never>] = [:]
@@ -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) {
@@ -33,6 +33,7 @@ public final class MemoryModel {
private var indexDirty = false
private var reviews: [UUID: Task<Void, Never>] = [:]
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()
}
}
@@ -561,8 +561,10 @@ public actor AgentSSHAuthenticationContext {
}
private struct Pending {
var id: UUID
var generation: UInt64
var task: Task<AgentHostResolver.InteractiveResolution, any Error>
var waiters: [UUID: CheckedContinuation<AgentHostResolver.InteractiveResolution, any Error>]
}
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<AgentHostResolver.InteractiveResolution, any Error>
) {
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<any SSHConnectionProtocol, any Error>
var waiters: [UUID: CheckedContinuation<OpenedConnection, any Error>]
}
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<OpenedConnection, any Error>) 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<any SSHConnectionProtocol, any Error>
) async {
guard let opening = pending[key], opening.id == id else {
if case .success(let connection) = initialResult {
await connection.disconnect()
}
return
}
let result: Result<OpenedConnection, any Error>
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(
+30 -19
View File
@@ -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)
}
@@ -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,
+63 -20
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)
+46
View File
@@ -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
}
+241 -5
View File
@@ -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("<use_skill name=\"disk-triage\">"))
#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<Void, Never>] = []
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<AgentModelStreamEvent, any Error> {
AsyncThrowingStream { continuation in
calls.called()
continuation.yield(.finished(.completed))
continuation.finish()
}
}
}
private actor MobileTerminalSaveGateRepository: AgentRunPersisting {
private var terminalSaveEntered = false
private var terminalSaveWaiters: [CheckedContinuation<Void, Never>] = []
private var terminalReleaseWaiters: [CheckedContinuation<Void, Never>] = []
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.
@@ -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)
}
}
@@ -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")
+6 -1
View File
@@ -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 {
@@ -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 {
@@ -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 {
+7 -1
View File
@@ -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()
+15 -1
View File
@@ -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 {
+48 -11
View File
@@ -42,6 +42,7 @@ nonisolated enum HostOpenTarget: Hashable, Sendable {
@Observable
final class AppModel {
private var terminationTask: Task<Void, Never>?
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<UUID>()
/// 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<Void, Never> {
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
}
+31 -14
View File
@@ -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<Void, Never>, 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) {
@@ -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(
@@ -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) {
+10 -4
View File
@@ -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 {
+71
View File
@@ -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 {
@@ -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 {
+355
View File
@@ -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<AgentModelStreamEvent, any Error> {
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<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
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<Void, Never>] = []
private var terminalReleaseWaiters: [CheckedContinuation<Void, Never>] = []
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) {}
}
+49 -19
View File
@@ -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 }
+14 -4
View File
@@ -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])
+5 -1
View File
@@ -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
@@ -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<AnyView>
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
}
+15 -15
View File
@@ -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" */;