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:
commit
68c137dafc
38 files changed
+3067
-323
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" */;
|
||||
|
||||
Reference in new issue
Block a user