From c1ae0dc0a2b06a64e832a14538dd4beb6820c57b Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 27 Sep 2026 19:08:29 +0800 Subject: [PATCH] feat(assistant): add cross-chat agent collaboration Add host-owned chat creation, model discovery, messaging, reading, and waiting with durable delivery receipts. Keep tool presentation compact, mark agent-authored messages, and prevent edits that would remove tool activity. Adopt the signed AgentKit 0.16.0 release in the package requirement, resolved revision, and submodule. --- AgentKit | 2 +- .../AgentReplayPersistenceTests.swift | 11 +- .../AssistantChatCollaborationEdgeTests.swift | 212 +++++++ ...antChatCollaborationPersistenceTests.swift | 76 +++ ...ssistantChatCollaborationReviewTests.swift | 337 +++++++++++ .../AssistantExecutionControllerTests.swift | 4 +- .../CloudSyncSafetyPersistenceTests.swift | 2 +- .../NotificationPreferencesTests.swift | 2 +- .../PersistenceCompatibilityTests.swift | 31 + Frameworks/Resources/Localizable.xcstrings | 51 ++ .../Agent/AssistantChatCollaboration.swift | 569 ++++++++++++++++++ .../Agent/AssistantExecutionController.swift | 95 ++- .../Agent/AssistantLibraryModel.swift | 1 + .../Agent/ConversationModel.swift | 170 +++++- .../FeatureModels/MemoryModel.swift | 2 +- ...ssistantChatCollaborationPersistence.swift | 178 ++++++ .../TermindPersistence/HostPersistence.swift | 15 + .../SwiftDataAgentRunRepository.swift | 51 ++ .../TermindPersistence.swift | 2 +- .../AgentToolPresentation.swift | 47 +- .../AssistantToolDetails.swift | 25 +- .../Strings/Assistant.swift | 12 + .../Agent/AssistantToolRegistry.swift | 6 +- TFI/App/MobileAppModel.swift | 2 + .../Agent/MobileAssistantModel.swift | 46 +- .../AI/MobileAssistantConversation.swift | 60 +- .../MobileAssistantAgentOriginTests.swift | 47 ++ TFM/App/AppDependencies.swift | 3 + TFM/App/AppModel.swift | 40 +- TFM/App/TermindApp.swift | 3 + .../AI/ConversationTranscriptView.swift | 5 + TFM/Features/AI/TranscriptListView.swift | 16 + TFM/Features/AI/TranscriptNativeRows.swift | 74 ++- TFMTests/AgentOriginRenderingTests.swift | 100 +++ TFMTests/AgentPersistenceTests.swift | 94 +-- TFMTests/AgentTokenUsageTests.swift | 7 +- TFMTests/AgentToolPresentationTests.swift | 134 +++++ TFMTests/ChatComposerStateTests.swift | 47 +- Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- 40 files changed, 2483 insertions(+), 102 deletions(-) create mode 100644 FrameworkTests/AssistantChatCollaborationEdgeTests.swift create mode 100644 FrameworkTests/AssistantChatCollaborationPersistenceTests.swift create mode 100644 FrameworkTests/AssistantChatCollaborationReviewTests.swift create mode 100644 Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift create mode 100644 Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift create mode 100644 TFITests/MobileAssistantAgentOriginTests.swift create mode 100644 TFMTests/AgentOriginRenderingTests.swift diff --git a/AgentKit b/AgentKit index 4540847d..f78416b2 160000 --- a/AgentKit +++ b/AgentKit @@ -1 +1 @@ -Subproject commit 4540847d4762f9040be7a8bab4cadd82e494c36d +Subproject commit f78416b2b8523d09950872b96f0de841e2d704e8 diff --git a/FrameworkTests/AgentReplayPersistenceTests.swift b/FrameworkTests/AgentReplayPersistenceTests.swift index ecd05fb0..d956b920 100644 --- a/FrameworkTests/AgentReplayPersistenceTests.swift +++ b/FrameworkTests/AgentReplayPersistenceTests.swift @@ -31,7 +31,10 @@ struct AgentReplayPersistenceTests { snapshot.id = UUID() snapshot.startedAt = snapshot.startedAt.addingTimeInterval(1) snapshot.finishedAt = snapshot.startedAt - snapshot.messages.append(.init(role: .user, text: "continue", contextSnapshot: .init())) + let continued = AgentTranscriptMessage( + role: .user, text: "continue", authoredText: "continue", + contextSnapshot: .init(sessionContext: "host B")) + snapshot.messages.append(continued) try await repository.save(snapshot) let restored = try #require(try await repository.loadConversations().first) #expect(restored.messages.first?.contextSnapshot == first.contextSnapshot) @@ -39,7 +42,9 @@ struct AgentReplayPersistenceTests { #expect(restored.messages.first { $0.role == .tool }?.modelText == "fixed preview") #expect(try container.mainContext.fetchCount(FetchDescriptor()) == 3) let model = ConversationModel(messages: [], transcript: restored.messages) - let editable = try #require(model.editedUserMessage(first.id, authoredText: "edited")) + #expect(model.editedUserMessage(first.id, authoredText: "edited") == nil) + let editable = try #require( + model.editedUserMessage(continued.id, authoredText: "edited")) #expect(editable.text == "edited") #expect(editable.contextSnapshot == nil) try await repository.deleteConversation(snapshot.conversationID) @@ -224,7 +229,7 @@ struct AgentReplayPersistenceTests { let controller = AssistantExecutionController( ai: AIConfigurationModel(), skills: SkillLibraryModel(), repository: repository, authorize: { true }, - makeRegistry: { _, _, _, _, _, _ in AgentToolRegistry([]) }, + makeRegistry: { _, _, _, _, _, _, _ in AgentToolRegistry([]) }, title: { _ in "Title" } ) let operations = AssistantConversationOperations(repository: repository) diff --git a/FrameworkTests/AssistantChatCollaborationEdgeTests.swift b/FrameworkTests/AssistantChatCollaborationEdgeTests.swift new file mode 100644 index 00000000..cc961c6d --- /dev/null +++ b/FrameworkTests/AssistantChatCollaborationEdgeTests.swift @@ -0,0 +1,212 @@ +import AgentKit +import Foundation +import TermindDomain +import Testing + +@testable import TermindApplication + +@Suite(.timeLimit(.minutes(1))) +@MainActor +struct AssistantChatCollaborationEdgeTests { + @Test + func metadataFailureAfterDurablePromptStillAcceptsCreatedChat() async throws { + let repository = MetadataRejectingAgentRunRepository() + let fixture = ChatCollaborationEdgeFixture(repository: repository) + + let result = try await fixture.coordinator.createChat( + prompt: "Durable delegated task", model: nil, title: "Delegated", + source: fixture.sourceContext(), sourceRunID: UUID(), + sourceToolCallID: "metadata-failure") + + let id = try #require( + result.objectValue?["chat_id"]?.stringValue.flatMap(UUID.init(uuidString:))) + #expect(result.objectValue?["status"]?.stringValue == "accepted") + #expect(fixture.store.conversation(id: id) != nil) + let stored = try #require(try await repository.loadConversations().first { $0.id == id }) + #expect(stored.messages.map(\.text) == ["Durable delegated task"]) + #expect(stored.messages.first?.origin?.sourceConversationID == fixture.source.id) + } + + @Test + func nonzeroWaitTimesOutAndCancelsItsInterjectionWaiter() async throws { + let fixture = ChatCollaborationEdgeFixture() + let target = fixture.addTarget() + let signal = AgentInterjectionSignal() + + let result = try await fixture.coordinator.waitForChats( + ids: [target.id], timeoutMilliseconds: 20, source: fixture.sourceContext(), + untilUserInterjects: { await signal.wait() }) + + #expect(result.objectValue?["timed_out"]?.boolValue == true) + #expect(result.objectValue?["interrupted"]?.boolValue == false) + } + + @Test + func userInterjectionEndsNonzeroWaitWithoutChangingTargetState() async throws { + let fixture = ChatCollaborationEdgeFixture() + let target = fixture.addTarget() + target.handle(.runState(.running)) + let signal = AgentInterjectionSignal() + let waiting = Task { @MainActor in + try await fixture.coordinator.waitForChats( + ids: [target.id], timeoutMilliseconds: 60_000, + source: fixture.sourceContext(), untilUserInterjects: { await signal.wait() }) + } + + try await Task.sleep(for: .milliseconds(10)) + signal.arm() + let result = try await waiting.value + + #expect(result.objectValue?["timed_out"]?.boolValue == false) + #expect(result.objectValue?["interrupted"]?.boolValue == true) + #expect(target.runState == .running) + } + + @Test + func targetCompletionEndsNonzeroWaitAndCancelsInterjectionWaiter() async throws { + let fixture = ChatCollaborationEdgeFixture() + let target = fixture.addTarget() + target.handle(.runState(.running)) + let signal = AgentInterjectionSignal() + let waiting = Task { @MainActor in + try await fixture.coordinator.waitForChats( + ids: [target.id], timeoutMilliseconds: 60_000, + source: fixture.sourceContext(), untilUserInterjects: { await signal.wait() }) + } + + try await Task.sleep(for: .milliseconds(10)) + target.handle(.runState(.completed)) + let result = try await waiting.value + + #expect(result.objectValue?["timed_out"]?.boolValue == false) + #expect(result.objectValue?["interrupted"]?.boolValue == false) + } + + @Test + func cancellingNonzeroWaitDoesNotRemainRegisteredUntilItsDeadline() async throws { + let fixture = ChatCollaborationEdgeFixture() + let target = fixture.addTarget() + target.handle(.runState(.running)) + let signal = AgentInterjectionSignal() + let clock = ContinuousClock() + let waiting = Task { @MainActor in + try await fixture.coordinator.waitForChats( + ids: [target.id], timeoutMilliseconds: 60_000, + source: fixture.sourceContext(), untilUserInterjects: { await signal.wait() }) + } + let cleanup = Task { + try? await Task.sleep(for: .milliseconds(200)) + signal.arm() + } + + await Task.yield() + let start = clock.now + waiting.cancel() + _ = try await waiting.value + let returnedBeforeCleanup = clock.now - start < .milliseconds(100) + signal.arm() + cleanup.cancel() + + #expect(returnedBeforeCleanup) + } +} + +@MainActor +private final class ChatCollaborationEdgeFixture { + let source = ConversationModel() + let store: AssistantConversationStore + let receipts = InMemoryAssistantChatCollaborationStore() + let coordinator: AssistantChatCoordinator + private let sourceModel: AIModelRef + private let targetModel: AIModelRef + + init(repository: any AgentRunPersisting = InMemoryAgentRunRepository()) { + let provider = ModelProvider( + name: "Configured", inferenceURL: "https://example.invalid/v1/chat/completions", + credentialRef: "chat-edge-key", + models: [AIModel(id: "source-model"), AIModel(id: "target-model")]) + sourceModel = AIModelRef(modelProviderID: provider.id, modelID: "source-model") + targetModel = AIModelRef(modelProviderID: provider.id, modelID: "target-model") + let ai = AIConfigurationModel( + modelProviders: [provider], lastSelectedModel: targetModel, persistsSecrets: false) + ai.setSecret("test-only", for: provider.credentialRef) + let selections = AssistantModelSelectionController( + ai: ai, store: InMemoryAssistantModelSelectionStore()) + source.model = sourceModel + source.customTitle = "Source" + store = AssistantConversationStore(conversations: [source]) + let controller = AssistantExecutionController( + ai: ai, skills: SkillLibraryModel(), repository: repository, + modelSelections: selections, authorize: { true }, + makeRegistry: { _, _, _, _, _, _, _ in AgentToolRegistry([]) }, + title: { _ in "Generated" }) + coordinator = AssistantChatCoordinator( + conversations: store, ai: ai, modelSelections: selections, + collaborationStore: receipts, + prepareDelivery: { text, origin, target in + controller.prepareIncomingAgentMessage(text, origin: origin, in: target) + }, + persistDelivery: { prepared, target in + try await controller.persistPreparedAgentDelivery(prepared, in: target) + }, + startDelivery: { _, _ in }, + cancelDelivery: { prepared, target in + await controller.cancelPreparedAgentDelivery(prepared, in: target) + }) + } + + func sourceContext() -> AgentChatSourceContext { + AgentChatSourceContext( + conversationID: source.id, title: source.derivedTitle, + modelReference: AssistantChatCoordinator.reference(sourceModel), + permissionMode: .askForApproval, mode: .acting) + } + + func addTarget() -> ConversationModel { + let target = ConversationModel() + target.model = targetModel + store.insert(target) + return target + } +} + +private actor MetadataRejectingAgentRunRepository: AgentRunPersisting { + private let base = InMemoryAgentRunRepository() + + func save(_ snapshot: AgentRunSnapshot) async throws { await base.save(snapshot) } + func record(_ event: AgentEvent, runID: UUID) async throws { + await base.record(event, runID: runID) + } + func persistCompaction( + _ record: AgentCompactionRecord, conversationID: UUID + ) async throws { + try await base.persistCompaction(record, conversationID: conversationID) + } + func loadConversations() async throws -> [StoredAgentConversation] { + await base.loadConversations() + } + func deleteConversation(_ id: UUID) async throws { + await base.deleteConversation(id) + } + func setTitle(_ title: String, conversationID: UUID) async throws { + await base.setTitle(title, conversationID: conversationID) + } + func setConversationMetadata( + _ metadata: StoredAgentConversationMetadata, conversationID: UUID + ) async throws { + throw CocoaError(.fileWriteNoPermission) + } + func reorderConversations(_ updates: [StoredAgentConversationOrder]) async throws { + try await base.reorderConversations(updates) + } + func truncateConversation( + _ id: UUID, fromUserMessageID messageID: UUID, + replacingWith message: AgentTranscriptMessage + ) async throws { + try await base.truncateConversation( + id, fromUserMessageID: messageID, replacingWith: message) + } + func createConversation(_ conversation: StoredAgentConversation) async throws { + try await base.createConversation(conversation) + } +} diff --git a/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift b/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift new file mode 100644 index 00000000..de9a0c20 --- /dev/null +++ b/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift @@ -0,0 +1,76 @@ +import AgentKit +import Foundation +import SwiftData +import Testing + +@testable import TermindApplication +@testable import TermindPersistence + +@Suite +@MainActor +struct AssistantChatCollaborationPersistenceTests { + @Test + func inMemoryDeliveredReceiptRejectsStaleSave() throws { + let store = InMemoryAssistantChatCollaborationStore() + let receipt = AssistantChatDeliveryReceipt( + sourceConversationID: UUID(), targetConversationID: UUID(), + sourceRunID: UUID(), sourceToolCallID: "send", messageID: UUID(), + state: .queued) + try store.save(receipt) + try store.updateReceipt(messageID: receipt.messageID, state: .delivered, failure: nil) + + try store.save(receipt) + + let saved = try #require( + try store.receipt( + sourceRunID: receipt.sourceRunID, sourceToolCallID: receipt.sourceToolCallID)) + #expect(saved.state == .delivered) + #expect(saved.failure == nil) + } + + @Test + func messagePersistenceConfirmsReceiptAcrossContextsWithoutLaterDowngrade() async throws { + let container = try TermindPersistence.makeContainer(inMemory: true) + let receiptStore = SwiftDataAssistantChatCollaborationStore( + context: ModelContext(container)) + let repository = SwiftDataAgentRunRepository(context: ModelContext(container)) + let sourceConversationID = UUID() + let targetConversationID = UUID() + let sourceRunID = UUID() + let message = AgentTranscriptMessage( + role: .user, text: "Routed message", + origin: AgentMessageOrigin( + sourceConversationID: sourceConversationID, sourceRunID: sourceRunID, + sourceToolCallID: "send")) + let queued = AssistantChatDeliveryReceipt( + sourceConversationID: sourceConversationID, + targetConversationID: targetConversationID, + sourceRunID: sourceRunID, + sourceToolCallID: "send", + messageID: message.id, + state: .queued) + try receiptStore.save(queued) + + let now = Date.now + try await repository.save( + AgentRunSnapshot( + conversationID: targetConversationID, state: .completed, + permissionMode: .askForApproval, messages: [message], startedAt: now, + finishedAt: now)) + + #expect( + try receiptStore.receipt( + sourceRunID: sourceRunID, sourceToolCallID: "send" + )?.state == .delivered) + + // A caller may still hold the queued reservation it inserted before the + // repository transaction. Saving that stale value must remain monotonic. + try receiptStore.save(queued) + try receiptStore.updateReceipt( + messageID: message.id, state: .undelivered, failure: "stale callback") + let final = try #require( + try receiptStore.receipt(sourceRunID: sourceRunID, sourceToolCallID: "send")) + #expect(final.state == .delivered) + #expect(final.failure == nil) + } +} diff --git a/FrameworkTests/AssistantChatCollaborationReviewTests.swift b/FrameworkTests/AssistantChatCollaborationReviewTests.swift new file mode 100644 index 00000000..0a7840c8 --- /dev/null +++ b/FrameworkTests/AssistantChatCollaborationReviewTests.swift @@ -0,0 +1,337 @@ +import AgentKit +import Foundation +import TermindDomain +import Testing + +@testable import TermindApplication + +@Suite(.timeLimit(.minutes(1))) @MainActor +struct AssistantChatCollaborationReviewTests { + @Test func modelCatalogUsesOnlyEnabledChatConfigurationAndReportsReadiness() async throws { + let fixture = ChatReviewFixture() + let disabled = ModelProvider(name: "Disabled", isEnabled: false, models: [AIModel(id: "hidden")]) + fixture.ai.upsert(disabled) + var provider = fixture.provider + provider.models.append(AIModel(id: "embedding", kind: .embedding)) + fixture.ai.upsert(provider) + + let unready = try await fixture.coordinator.configuredChatModels() + #expect(Set(unready.map(\.name)) == ["source-model", "target-model"]) + #expect(unready.allSatisfy { !$0.isReady }) + fixture.ai.setSecret("test-only-secret", for: provider.credentialRef) + let ready = try await fixture.coordinator.configuredChatModels() + #expect(ready.allSatisfy { $0.isReady }) + #expect(ready.allSatisfy { !$0.reference.contains("test-only-secret") }) + } + + @Test func creationInheritsCapturedPolicyWithoutChangingTheManualModelOrCopyingContext() async throws { + let fixture = ChatReviewFixture() + fixture.source.composer.draft = "Unsent human draft" + fixture.source.attach(ContextChip(kind: .file, label: "private.txt", content: "private attachment")) + fixture.source.composer.armedSkillName = "source-only-skill" + fixture.source.permissionMode = .fullAccess + fixture.source.isPlanning = false + let captured = fixture.sourceContext(mode: .planning, permission: .askForApproval) + let runID = UUID() + + let result = try await fixture.coordinator.createChat( + prompt: "Explicit task summary", model: nil, title: "Independent task", source: captured, + sourceRunID: runID, sourceToolCallID: "create-once") + let target = try fixture.target(from: result) + try await waitUntil { !target.isPreparingAgentRun } + + #expect(target !== fixture.source) + #expect(target.model == fixture.sourceModel) + #expect(target.permissionMode == .askForApproval) + #expect(target.isPlanning) + #expect(fixture.ai.lastSelectedModel == fixture.targetModel) + #expect(target.composer.draft.isEmpty) + #expect(target.composer.attachments.isEmpty) + #expect(target.composer.armedSkillName == nil) + let prompt = try #require(target.transcript.first) + #expect(prompt.text == "Explicit task summary") + #expect(prompt.images.isEmpty && prompt.contextAttachments.isEmpty) + #expect(prompt.authoredText == nil) + #expect(prompt.origin?.sourceConversationID == fixture.source.id) + #expect(prompt.origin?.sourceRunID == runID) + #expect(prompt.origin?.sourceToolCallID == "create-once") + #expect(prompt.contextSnapshot?.sessionContext == nil) + #expect(!target.canEditUserMessage(prompt.id)) + #expect(fixture.source.composer.draft == "Unsent human draft") + #expect(fixture.source.composer.attachments.count == 1) + + let restored = try #require(await fixture.repository.loadConversations().first { $0.id == target.id }) + #expect(restored.metadata.customTitle == "Independent task") + #expect(restored.messages.first?.origin == prompt.origin) + let retried = try await fixture.coordinator.createChat( + prompt: "Do not send this twice", model: nil, title: nil, source: captured, + sourceRunID: runID, sourceToolCallID: "create-once") + #expect(try fixture.target(from: retried).id == target.id) + #expect(fixture.store.conversations.count == 2) + #expect(target.messages.filter { $0.role == .user }.count == 1) + } + + @Test func explicitUnavailableModelFailsWithoutFallbackOrCreation() async throws { + let fixture = ChatReviewFixture() + let unavailable = AIModelRef(modelProviderID: fixture.provider.id, modelID: "removed") + await #expect(throws: AgentToolError.self) { + try await fixture.coordinator.createChat( + prompt: "task", model: AssistantChatCoordinator.reference(unavailable), title: nil, + source: fixture.sourceContext(), sourceRunID: UUID(), sourceToolCallID: "unavailable") + } + #expect(fixture.store.conversations.map(\.id) == [fixture.source.id]) + #expect(await fixture.repository.loadConversations().isEmpty) + #expect(fixture.ai.lastSelectedModel == fixture.targetModel) + } + + @Test func idleDeliveryPreservesTargetComposerAndSettingsAndDeduplicatesRetries() async throws { + let fixture = ChatReviewFixture() + let target = fixture.addTarget() + target.permissionMode = .fullAccess + target.reasoningEffort = .high + target.composer.draft = "Keep writing this" + let attachment = ContextChip(kind: .pastedText, label: "Unsent attachment", content: "private draft") + target.attach(attachment) + target.composer.armedSkillName = "unsent-skill" + let runID = UUID() + + let result = try await fixture.coordinator.sendToChat( + id: target.id, message: "Incoming task", source: fixture.sourceContext(), + sourceRunID: runID, sourceToolCallID: "send-once") + try await waitUntil { !target.isPreparingAgentRun } + #expect(result.objectValue?["status"]?.stringValue == "delivered") + #expect(target.model == fixture.targetModel) + #expect(target.permissionMode == .fullAccess) + #expect(target.reasoningEffort == .high) + #expect(target.composer.draft == "Keep writing this") + #expect(target.composer.attachments.map(\.id) == [attachment.id]) + #expect(target.composer.armedSkillName == "unsent-skill") + let incoming = try #require(target.messages.first { $0.role == .user }) + #expect(incoming.text == "Incoming task") + #expect(incoming.contextChips.isEmpty && incoming.images.isEmpty) + #expect(incoming.origin?.sourceConversationID == fixture.source.id) + #expect(!target.canEditUserMessage(incoming.id)) + + _ = try await fixture.coordinator.sendToChat( + id: target.id, message: "Retry body must not be delivered", source: fixture.sourceContext(), + sourceRunID: runID, sourceToolCallID: "send-once") + #expect(target.messages.filter { $0.role == .user }.count == 1) + } + + @Test func receiptWriteFailureReleasesPreparationAndPreservesComposer() async throws { + let fixture = ChatReviewFixture(receipts: RejectingChatReviewReceipts()) + let target = fixture.addTarget() + target.composer.draft = "keep" + await #expect(throws: ChatReviewWriteFailure.self) { + try await fixture.coordinator.sendToChat( + id: target.id, message: "Not accepted", source: fixture.sourceContext(), + sourceRunID: UUID(), sourceToolCallID: "failed-receipt") + } + #expect(!target.isPreparingAgentRun) + #expect(target.messages.isEmpty) + #expect(target.composer.draft == "keep") + #expect(await fixture.repository.loadConversations().isEmpty) + } + + @Test func retryAfterTargetDeletionPreservesTheHistoricalDeliveryWithoutRecreatingTheChat() async throws { + let fixture = ChatReviewFixture() + let runID = UUID() + let result = try await fixture.coordinator.createChat( + prompt: "Delivered task", model: nil, title: "Deleted target", source: fixture.sourceContext(), + sourceRunID: runID, sourceToolCallID: "deleted-target") + let target = try fixture.target(from: result) + try await waitUntil { !target.isPreparingAgentRun } + fixture.store.remove(target.id) + + let retried = try await fixture.coordinator.createChat( + prompt: "Do not recreate", model: nil, title: nil, source: fixture.sourceContext(), + sourceRunID: runID, sourceToolCallID: "deleted-target") + #expect(retried.objectValue?["status"]?.stringValue == "delivered") + #expect(fixture.store.conversations.map(\.id) == [fixture.source.id]) + let receipt = try #require( + try fixture.receipts.receipt(sourceRunID: runID, sourceToolCallID: "deleted-target")) + #expect(receipt.state == .delivered) + } + + @Test func planSenderCannotSteerAnActingRunEvenWhenItsUIToggleChanges() async throws { + let fixture = ChatReviewFixture() + let target = fixture.addTarget() + fixture.startSuspendedRun(target, planning: false) + target.isPlanning = true + await #expect(throws: AgentToolError.self) { + try await fixture.coordinator.sendToChat( + id: target.id, message: "Plan only", source: fixture.sourceContext(mode: .planning), + sourceRunID: UUID(), sourceToolCallID: "plan-to-acting") + } + #expect(target.pendingSteering.isEmpty) + await target.stopAndWait() + } + + @Test func queuedAgentDeliveryCancelledBeforeTheBoundaryNeverBecomesAHumanDraft() async throws { + let fixture = ChatReviewFixture() + let target = fixture.addTarget() + fixture.startSuspendedRun(target, planning: true) + target.isPlanning = false + target.composer.draft = "Human draft" + let runID = UUID() + _ = try await fixture.coordinator.sendToChat( + id: target.id, message: "Agent-only message", source: fixture.sourceContext(mode: .planning), + sourceRunID: runID, sourceToolCallID: "queued") + let pending = try #require(target.pendingSteering.first) + #expect(pending.message.origin != nil) + target.stop() + await target.stopAndWait() + #expect(target.composer.draft == "Human draft") + #expect(target.pendingSteering.isEmpty) + let receipt = try #require(try fixture.receipts.receipt(sourceRunID: runID, sourceToolCallID: "queued")) + #expect(receipt.state == .undelivered) + } + + @Test func waitDoesNotReportAnOldCompletedRunWhileTheNextRunIsPreparing() async throws { + let fixture = ChatReviewFixture() + let target = fixture.addTarget() + target.handle(.runState(.completed)) + let preparationID = UUID() + let didPrepare = target.beginAgentPreparation(id: preparationID, cancellation: { Task {} }) + #expect(didPrepare) + let waiting = try await fixture.coordinator.waitForChats( + ids: [target.id], timeoutMilliseconds: 0, source: fixture.sourceContext(), + untilUserInterjects: {}) + #expect(waiting.objectValue?["timed_out"]?.boolValue == true) + #expect(waiting.objectValue?["chats"]?.arrayValue?.first?.objectValue?["status"]?.stringValue == "preparing") + target.finishAgentPreparation(id: preparationID) + let completed = try await fixture.coordinator.waitForChats( + ids: [target.id], timeoutMilliseconds: 0, source: fixture.sourceContext(), + untilUserInterjects: {}) + #expect(completed.objectValue?["timed_out"]?.boolValue == false) + } + + @Test func chatListingExcludesArchivesAndReadReturnsBoundedVisibleText() async throws { + let fixture = ChatReviewFixture() + for index in 0..<24 { + let target = fixture.addTarget() + target.customTitle = "Task \(index)" + } + let archived = fixture.addTarget() + archived.archivedAt = .now + let result = try await fixture.coordinator.listChats(cursor: nil, includeArchived: false) + let rows = try #require(result.objectValue?["chats"]?.arrayValue) + #expect(rows.count == 20) + #expect(rows.allSatisfy { $0.objectValue?["archived"]?.boolValue == false }) + #expect(rows.allSatisfy { $0.objectValue?["messages"] == nil }) + let next = try #require(result.objectValue?["next_cursor"]?.stringValue) + let second = try await fixture.coordinator.listChats(cursor: next, includeArchived: false) + #expect(second.objectValue?["chats"]?.arrayValue?.count == 5) + + let target = fixture.addTarget() + target.messages = [ChatMessage(role: .assistant, text: String(repeating: "x", count: 20_000))] + let read = try await fixture.coordinator.readChat(id: target.id, cursor: nil) + #expect(read.objectValue?["truncated"]?.boolValue == true) + let text = try #require(read.objectValue?["messages"]?.arrayValue?.first?.objectValue?["text"]?.stringValue) + #expect(text.count == 8_000) + } + + private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while !condition() { + guard clock.now < deadline else { throw ChatReviewTimeout() } + try await Task.sleep(for: .milliseconds(1)) + } + } +} + +@MainActor +private final class ChatReviewFixture { + let provider: ModelProvider + let ai: AIConfigurationModel + let source = ConversationModel() + let store: AssistantConversationStore + let repository = InMemoryAgentRunRepository() + let receipts: any AssistantChatCollaborationStoring + let controller: AssistantExecutionController + let coordinator: AssistantChatCoordinator + let sourceModel: AIModelRef + let targetModel: AIModelRef + + init(receipts: any AssistantChatCollaborationStoring = InMemoryAssistantChatCollaborationStore()) { + let provider = ModelProvider( + name: "Configured", inferenceURL: "https://example.invalid/v1/chat/completions", + credentialRef: "chat-review-key", models: [AIModel(id: "source-model"), AIModel(id: "target-model")]) + self.provider = provider + sourceModel = AIModelRef(modelProviderID: provider.id, modelID: "source-model") + targetModel = AIModelRef(modelProviderID: provider.id, modelID: "target-model") + ai = AIConfigurationModel(modelProviders: [provider], lastSelectedModel: targetModel, persistsSecrets: false) + let selections = AssistantModelSelectionController(ai: ai, store: InMemoryAssistantModelSelectionStore()) + source.model = sourceModel + source.customTitle = "Source" + store = AssistantConversationStore(conversations: [source]) + self.receipts = receipts + let controller = AssistantExecutionController( + ai: ai, skills: SkillLibraryModel(), repository: repository, modelSelections: selections, + authorize: { true }, makeRegistry: { _, _, _, _, _, _, _ in AgentToolRegistry([]) }, + title: { _ in "Generated" }) + self.controller = controller + coordinator = AssistantChatCoordinator( + conversations: store, ai: ai, modelSelections: selections, collaborationStore: receipts, + prepareDelivery: { text, origin, target in + controller.prepareIncomingAgentMessage(text, origin: origin, in: target) + }, + persistDelivery: { prepared, target in + try await controller.persistPreparedAgentDelivery(prepared, in: target) + }, + startDelivery: { prepared, target in controller.startPreparedAgentDelivery(prepared, in: target) }, + cancelDelivery: { prepared, target in await controller.cancelPreparedAgentDelivery(prepared, in: target) }) + } + + func sourceContext( + mode: AgentRunMode = .acting, permission: AgentPermissionMode = .askForApproval + ) -> AgentChatSourceContext { + AgentChatSourceContext( + conversationID: source.id, title: source.derivedTitle, + modelReference: AssistantChatCoordinator.reference(sourceModel), permissionMode: permission, mode: mode) + } + + func addTarget() -> ConversationModel { + let target = ConversationModel() + target.model = targetModel + store.insert(target) + return target + } + + func target(from result: AgentJSONValue) throws -> ConversationModel { + let raw = try #require(result.objectValue?["chat_id"]?.stringValue) + let id = try #require(UUID(uuidString: raw)) + return try #require(store.conversation(id: id)) + } + + func startSuspendedRun(_ target: ConversationModel, planning: Bool) { + target.start( + runtime: AgentRuntime( + model: SuspendedChatReviewModel(), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }), repository: repository), + request: AgentRunRequest( + conversationID: target.id, prompt: "Existing run", + permissionMode: .askForApproval, isPlanning: planning), repository: repository) + } +} + +private struct SuspendedChatReviewModel: AgentModelStreaming { + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { _ in } + } +} + +private struct ChatReviewWriteFailure: Error {} +private struct ChatReviewTimeout: Error {} + +@MainActor +private struct RejectingChatReviewReceipts: AssistantChatCollaborationStoring { + let confirmsDeliveryDuringMessagePersistence = false + func receipt(sourceRunID: UUID, sourceToolCallID: String) throws -> AssistantChatDeliveryReceipt? { nil } + func save(_ receipt: AssistantChatDeliveryReceipt) throws { throw ChatReviewWriteFailure() } + func updateReceipt(messageID: UUID, state: AgentMessageDeliveryState, failure: String?) throws {} + func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] { [] } + func save(_ relation: AssistantChatCreationRelation) throws { throw ChatReviewWriteFailure() } + func deleteRelation(conversationID: UUID) throws {} +} diff --git a/FrameworkTests/AssistantExecutionControllerTests.swift b/FrameworkTests/AssistantExecutionControllerTests.swift index 7e6eff6c..521b62db 100644 --- a/FrameworkTests/AssistantExecutionControllerTests.swift +++ b/FrameworkTests/AssistantExecutionControllerTests.swift @@ -19,7 +19,7 @@ struct AssistantExecutionControllerTests { let registryCapture = RegistryCapture() let controller = makeController( ai: ai, skills: skills, repository: repository, memory: memory, - makeRegistry: { _, model, provider, catalog, _, _ in + makeRegistry: { _, model, provider, catalog, _, _, _ in registryCapture.record(model: model, provider: provider, catalog: catalog) return AgentToolRegistry([]) }) @@ -752,7 +752,7 @@ struct AssistantExecutionControllerTests { repository: any AgentRunPersisting = RecordingAgentRunRepository(), memory: MemoryModel? = nil, authorize: @escaping @MainActor () -> Bool = { true }, - makeRegistry: @escaping AssistantExecutionController.RegistryFactory = { _, _, _, _, _, _ in + makeRegistry: @escaping AssistantExecutionController.RegistryFactory = { _, _, _, _, _, _, _ in AgentToolRegistry([]) } ) -> AssistantExecutionController { diff --git a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift index 03b74845..8a919fc7 100644 --- a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift +++ b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift @@ -263,7 +263,7 @@ struct CloudSyncSafetyPersistenceTests { #expect(!TermindSchemaV40.models.contains { $0 == PersistentVaultSyncQuarantine.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentAIConfigurationSyncQuarantine.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentMetricsSyncQuarantine.self }) - #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV41.versionIdentifier) + #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV42.versionIdentifier) } @Test diff --git a/FrameworkTests/NotificationPreferencesTests.swift b/FrameworkTests/NotificationPreferencesTests.swift index 49c5f5c0..e6be4ea1 100644 --- a/FrameworkTests/NotificationPreferencesTests.swift +++ b/FrameworkTests/NotificationPreferencesTests.swift @@ -102,7 +102,7 @@ struct NotificationPreferencesTests { let envelope = SettingsEnvelope( key: payload.key, version: .next(after: nil, writerID: UUID()), payload: payload) #expect(!envelope.isValid) - #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV41.versionIdentifier) + #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV42.versionIdentifier) #expect(TermindSchemaV41.models.contains { $0 == PersistentNotificationPreferences.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentNotificationPreferences.self }) } diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index bd1856d3..3a7145fb 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -257,6 +257,7 @@ struct PersistenceCompatibilityTests { #expect(!TermindMigrationPlan.schemas.contains { $0.versionIdentifier == Schema.Version(32, 0, 0) }) #expect(!TermindMigrationPlan.schemas.contains { $0.versionIdentifier == Schema.Version(33, 0, 0) }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV34.versionIdentifier }) + #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV42.versionIdentifier }) #expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self }) #expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) #expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) @@ -402,6 +403,36 @@ struct PersistenceCompatibilityTests { #expect(try migrated.mainContext.fetchCount(FetchDescriptor()) == 0) } + @Test + func version41StoreMigratesToVersion42WithEmptyCollaborationSidecars() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("Termind.store") + let oldSchema = Schema(versionedSchema: TermindSchemaV41.self) + let oldConfiguration = ModelConfiguration( + "Termind", schema: oldSchema, url: url, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer( + for: oldSchema, configurations: [oldConfiguration]) + oldContainer?.mainContext.insert(PersistentHostGroup(HostGroup(name: "Before collaboration"))) + try oldContainer?.mainContext.save() + oldContainer = nil + + let migrated = try TermindPersistence.makeContainer(url: url) + #expect( + try migrated.mainContext.fetch(FetchDescriptor()).map(\.name) + == ["Before collaboration"]) + #expect( + try migrated.mainContext.fetchCount( + FetchDescriptor()) == 0) + #expect( + try migrated.mainContext.fetchCount( + FetchDescriptor()) == 0) + #expect( + try migrated.mainContext.fetchCount( + FetchDescriptor()) == 0) + } + @Test func version25StoreMigratesWithNoStartupSnippet() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index aecab19d..faff366d 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -2853,6 +2853,23 @@ } } }, + "Agent" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent" + } + } + } + }, "Agent error" : { "extractionState" : "manual", "localizations" : { @@ -2870,6 +2887,23 @@ } } }, + "Agent from %@" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "来自%@的Agent" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "來自%@的Agent" + } + } + } + }, "Algorithm" : { "extractionState" : "manual", "localizations" : { @@ -5033,6 +5067,23 @@ } } }, + "Chat details unavailable" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对话详情不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "對話詳細資料無法使用" + } + } + } + }, "Chats" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift b/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift new file mode 100644 index 00000000..836adebf --- /dev/null +++ b/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift @@ -0,0 +1,569 @@ +import AgentKit +import Foundation +import TermindDomain + +nonisolated public struct AssistantChatCreationRelation: Hashable, Sendable { + public init( + conversationID: UUID, sourceConversationID: UUID, + sourceRunID: UUID, sourceToolCallID: String + ) { + self.conversationID = conversationID + self.sourceConversationID = sourceConversationID + self.sourceRunID = sourceRunID + self.sourceToolCallID = sourceToolCallID + } + + public var conversationID: UUID + public var sourceConversationID: UUID + public var sourceRunID: UUID + public var sourceToolCallID: String +} + +nonisolated public struct AssistantChatDeliveryReceipt: Identifiable, Hashable, Sendable { + public init( + id: UUID = UUID(), sourceConversationID: UUID, targetConversationID: UUID, + sourceRunID: UUID, sourceToolCallID: String, messageID: UUID, + state: AgentMessageDeliveryState, createdAt: Date = .now, + updatedAt: Date = .now, failure: String? = nil + ) { + self.id = id + self.sourceConversationID = sourceConversationID + self.targetConversationID = targetConversationID + self.sourceRunID = sourceRunID + self.sourceToolCallID = sourceToolCallID + self.messageID = messageID + self.state = state + self.createdAt = createdAt + self.updatedAt = updatedAt + self.failure = failure + } + + public var id: UUID + public var sourceConversationID: UUID + public var targetConversationID: UUID + public var sourceRunID: UUID + public var sourceToolCallID: String + public var messageID: UUID + public var state: AgentMessageDeliveryState + public var createdAt: Date + public var updatedAt: Date + public var failure: String? +} + +@MainActor public protocol AssistantChatCollaborationStoring { + /// Whether delivery becomes durable in the same transaction that persists the + /// origin message. Stores with this guarantee must not be marked delivered by + /// the earlier in-memory runtime event. + var confirmsDeliveryDuringMessagePersistence: Bool { get } + func receipt(sourceRunID: UUID, sourceToolCallID: String) throws -> AssistantChatDeliveryReceipt? + func save(_ receipt: AssistantChatDeliveryReceipt) throws + func updateReceipt( + messageID: UUID, state: AgentMessageDeliveryState, failure: String? + ) throws + func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] + func save(_ relation: AssistantChatCreationRelation) throws + func deleteRelation(conversationID: UUID) throws +} + +@MainActor +public final class InMemoryAssistantChatCollaborationStore: AssistantChatCollaborationStoring { + private var storedReceipts: [UUID: AssistantChatDeliveryReceipt] = [:] + private var relations: [UUID: AssistantChatCreationRelation] = [:] + + public init() {} + + public var confirmsDeliveryDuringMessagePersistence: Bool { false } + + public func receipt(sourceRunID: UUID, sourceToolCallID: String) throws + -> AssistantChatDeliveryReceipt? + { + storedReceipts.values.first { + $0.sourceRunID == sourceRunID && $0.sourceToolCallID == sourceToolCallID + } + } + + public func save(_ receipt: AssistantChatDeliveryReceipt) throws { + if let existing = try self.receipt( + sourceRunID: receipt.sourceRunID, sourceToolCallID: receipt.sourceToolCallID) + { + guard existing.state != .delivered || receipt.state == .delivered else { return } + var updated = receipt + updated.id = existing.id + storedReceipts[existing.id] = updated + } else { + storedReceipts[receipt.id] = receipt + } + } + + public func updateReceipt( + messageID: UUID, state: AgentMessageDeliveryState, failure: String? + ) throws { + guard let id = storedReceipts.first(where: { $0.value.messageID == messageID })?.key, + var receipt = storedReceipts[id] + else { return } + guard receipt.state != .delivered || state == .delivered else { return } + receipt.state = state + receipt.failure = failure + receipt.updatedAt = .now + storedReceipts[id] = receipt + } + + public func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] { + storedReceipts.values.filter { $0.targetConversationID == targetConversationID } + .sorted { $0.createdAt < $1.createdAt } + } + + public func save(_ relation: AssistantChatCreationRelation) throws { + if relations[relation.conversationID] == nil { relations[relation.conversationID] = relation } + } + + public func deleteRelation(conversationID: UUID) throws { + relations[conversationID] = nil + } +} + +/// Shared local-device coordination used by both native app adapters. +@MainActor +public final class AssistantChatCoordinator: AgentChatModelCatalog, AgentChatCoordinating { + private let conversations: AssistantConversationStore + private let ai: AIConfigurationModel + private let modelSelections: AssistantModelSelectionController + private let collaborationStore: any AssistantChatCollaborationStoring + private let prepareDelivery: + @MainActor (String, AgentMessageOrigin, ConversationModel) -> AssistantExecutionController + .PreparedAgentDelivery? + private let persistDelivery: + @MainActor (AssistantExecutionController.PreparedAgentDelivery, ConversationModel) async throws -> Void + private let startDelivery: + @MainActor (AssistantExecutionController.PreparedAgentDelivery, ConversationModel) -> Void + private let cancelDelivery: + @MainActor (AssistantExecutionController.PreparedAgentDelivery, ConversationModel) async -> Void + private var waiters: [UUID: (ids: Set, continuation: AsyncStream.Continuation)] = [:] + + public init( + conversations: AssistantConversationStore, + ai: AIConfigurationModel, + modelSelections: AssistantModelSelectionController, + collaborationStore: any AssistantChatCollaborationStoring, + prepareDelivery: + @escaping @MainActor ( + String, AgentMessageOrigin, ConversationModel + ) -> AssistantExecutionController.PreparedAgentDelivery?, + persistDelivery: + @escaping @MainActor ( + AssistantExecutionController.PreparedAgentDelivery, ConversationModel + ) async throws -> Void, + startDelivery: + @escaping @MainActor ( + AssistantExecutionController.PreparedAgentDelivery, ConversationModel + ) -> Void, + cancelDelivery: + @escaping @MainActor ( + AssistantExecutionController.PreparedAgentDelivery, ConversationModel + ) async -> Void + ) { + self.conversations = conversations + self.ai = ai + self.modelSelections = modelSelections + self.collaborationStore = collaborationStore + self.prepareDelivery = prepareDelivery + self.persistDelivery = persistDelivery + self.startDelivery = startDelivery + self.cancelDelivery = cancelDelivery + } + + public func configuredChatModels() async throws -> [AgentChatModelDescription] { + ai.enabledModelProviders.flatMap { provider in + provider.chatModels.map { model in + AgentChatModelDescription( + reference: Self.reference( + AIModelRef(modelProviderID: provider.id, modelID: model.id)), + name: model.label, provider: provider.name, + abilities: model.abilities.map(\.rawValue).sorted(), + contextWindow: model.contextLength ?? 0, + supportedReasoning: ModelCapabilityResolver.reasoning( + model: model, provider: provider + ).supported.map(\.rawValue), + isReady: ai.modelSecret(for: provider) != nil + && !provider.inferenceURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } + } + } + + public func createChat( + prompt: String, model: String?, title: String?, source: AgentChatSourceContext, + sourceRunID: UUID, sourceToolCallID: String + ) async throws -> AgentJSONValue { + if let existing = try existingDelivery( + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID) + { + return existing + } + let target = ConversationModel() + target.permissionMode = source.permissionMode + target.isPlanning = source.mode == .planning + target.customTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines).nonempty + let selected: AIModelRef + if let model { + guard let explicit = Self.modelReference(model), ai.lookupChat(explicit) != nil else { + throw AgentToolError.invalidArguments("The requested Chat model is unavailable.") + } + selected = explicit + } else { + guard let inherited = Self.modelReference(source.modelReference), ai.lookupChat(inherited) != nil else { + throw AgentToolError.invalidArguments("The source Chat model is unavailable.") + } + selected = inherited + } + target.model = selected + try modelSelections.prepareForSend(target) + let origin = AgentMessageOrigin( + sourceConversationID: source.conversationID, sourceRunID: sourceRunID, + sourceToolCallID: sourceToolCallID, sourceTitle: source.title) + guard let prepared = prepareDelivery(prompt, origin, target) else { + throw AgentToolError.invalidArguments("The new chat could not be prepared.") + } + let receipt = AssistantChatDeliveryReceipt( + sourceConversationID: source.conversationID, targetConversationID: target.id, + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID, + messageID: prepared.message.id, state: .queued) + do { + try collaborationStore.save( + AssistantChatCreationRelation( + conversationID: target.id, sourceConversationID: source.conversationID, + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID)) + try collaborationStore.save(receipt) + try await persistDelivery(prepared, target) + } catch { + await cancelDelivery(prepared, target) + try? collaborationStore.updateReceipt( + messageID: prepared.message.id, state: .undelivered, + failure: error.localizedDescription) + try? collaborationStore.deleteRelation(conversationID: target.id) + throw error + } + try? collaborationStore.updateReceipt( + messageID: prepared.message.id, state: .delivered, failure: nil) + configure(target) + conversations.insert(target) + startDelivery(prepared, target) + return chatJSON(target, status: "accepted", receiptID: receipt.id) + } + + public func listChats(cursor: String?, includeArchived: Bool) async throws -> AgentJSONValue { + attachAll() + let offset = try Self.offset(cursor) + let candidates = conversations.conversations + .filter { includeArchived || !$0.isArchived } + .sorted { $0.updatedAt > $1.updatedAt } + guard offset <= candidates.count else { + throw AgentToolError.invalidArguments("The chat cursor is invalid.") + } + let page = Array(candidates.dropFirst(offset).prefix(20)) + var result: [String: AgentJSONValue] = [ + "chats": .array(page.map { chatJSON($0) }), + "count": .number(Double(page.count)), + ] + let next = offset + page.count + if next < candidates.count { result["next_cursor"] = .string(String(next)) } + return .object(result) + } + + public func readChat(id: UUID, cursor: String?) async throws -> AgentJSONValue { + guard let conversation = conversations.conversation(id: id) else { + throw AgentToolError.invalidArguments("The chat does not exist.") + } + configure(conversation) + let offset = try Self.offset(cursor) + let visible = conversation.messages + guard offset <= visible.count else { + throw AgentToolError.invalidArguments("The chat cursor is invalid.") + } + let page = Array(visible.dropFirst(offset).prefix(20)) + var budget = 40_000 + var wasTruncated = false + let messages = page.map { message -> AgentJSONValue in + let allowed = max(0, min(8_000, budget)) + let text = String(message.text.prefix(allowed)) + if text.count < message.text.count { wasTruncated = true } + budget -= text.count + return .object([ + "role": .string(message.role == .user ? "user" : "assistant"), + "text": .string(text), + "agent_origin": .bool(message.origin != nil), + ]) + } + let toolRows = conversation.toolCards.suffix(20).map { card in + AgentJSONValue.object([ + "name": .string(card.descriptor.name), + "state": .string(card.state.rawValue), + ]) + } + let receipts = (try? collaborationStore.receipts(targetConversationID: id)) ?? [] + var result = chatObject(conversation) + result["messages"] = .array(messages) + result["message_count"] = .number(Double(page.count)) + result["tool_summaries"] = .array(toolRows) + result["deliveries"] = .array(receipts.suffix(20).map(receiptJSON)) + result["truncated"] = .bool(wasTruncated) + let next = offset + page.count + if next < visible.count { result["next_cursor"] = .string(String(next)) } + return .object(result) + } + + public func sendToChat( + id: UUID, message: String, source: AgentChatSourceContext, + sourceRunID: UUID, sourceToolCallID: String + ) async throws -> AgentJSONValue { + if let existing = try existingDelivery( + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID) + { + return existing + } + guard id != source.conversationID else { + throw AgentToolError.invalidArguments("A chat cannot send a message to itself.") + } + guard let target = conversations.conversation(id: id), !target.isArchived else { + throw AgentToolError.invalidArguments("The target chat is unavailable.") + } + guard !target.isCompacting, !target.isHistoryChanging, !target.isPreparingAgentRun else { + throw AgentToolError.invalidArguments("The target chat is busy changing history.") + } + if source.mode == .planning, !target.effectiveIsPlanning { + throw AgentToolError.invalidArguments("Plan mode can send only to another chat in Plan mode.") + } + configure(target) + let origin = AgentMessageOrigin( + sourceConversationID: source.conversationID, sourceRunID: sourceRunID, + sourceToolCallID: sourceToolCallID, sourceTitle: source.title) + let incoming = AgentTranscriptMessage( + role: .user, text: message, authoredText: nil, origin: origin) + if target.isStreaming { + var receipt = AssistantChatDeliveryReceipt( + sourceConversationID: source.conversationID, targetConversationID: target.id, + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID, + messageID: incoming.id, state: .queued) + try collaborationStore.save(receipt) + guard target.submitIncomingAgentSteering(incoming) else { + receipt.state = .undelivered + receipt.failure = "The target run ended before delivery." + receipt.updatedAt = .now + try collaborationStore.save(receipt) + return receiptJSON(receipt) + } + return chatJSON(target, status: "queued", receiptID: receipt.id) + } + guard let prepared = prepareDelivery(message, origin, target) else { + throw AgentToolError.invalidArguments("The target chat could not accept the message.") + } + let receipt = AssistantChatDeliveryReceipt( + sourceConversationID: source.conversationID, targetConversationID: target.id, + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID, + messageID: prepared.message.id, state: .queued) + do { + try collaborationStore.save(receipt) + try await persistDelivery(prepared, target) + } catch { + await cancelDelivery(prepared, target) + try? collaborationStore.updateReceipt( + messageID: prepared.message.id, state: .undelivered, + failure: error.localizedDescription) + throw error + } + try? collaborationStore.updateReceipt( + messageID: prepared.message.id, state: .delivered, failure: nil) + startDelivery(prepared, target) + return chatJSON(target, status: "delivered", receiptID: receipt.id) + } + + public func waitForChats( + ids: [UUID], timeoutMilliseconds: Int, source: AgentChatSourceContext, + untilUserInterjects: @escaping @concurrent @Sendable () async -> Void + ) async throws -> AgentJSONValue { + attachAll() + let missing = ids.filter { conversations.conversation(id: $0) == nil } + guard missing.isEmpty else { + throw AgentToolError.invalidArguments( + "One or more target chats no longer exist: \(missing.map(\.uuidString).joined(separator: ", ")).") + } + let ready = readyChats(ids) + if !ready.isEmpty || timeoutMilliseconds == 0 { + return waitJSON(ids: ids, timedOut: ready.isEmpty) + } + let stream = makeWaitStream(ids: Set(ids)) + let outcome = await withTaskGroup(of: Int.self, returning: Int.self) { group in + group.addTask { + for await _ in stream { return 0 } + return 0 + } + group.addTask { + try? await Task.sleep(for: .milliseconds(timeoutMilliseconds)) + return 1 + } + group.addTask { + await untilUserInterjects() + return 2 + } + let first = await group.next() ?? 1 + group.cancelAll() + return first + } + return waitJSON(ids: ids, timedOut: outcome == 1, interrupted: outcome == 2) + } + + private func configure(_ conversation: ConversationModel) { + let conversationID = conversation.id + conversation.chatStateDidChange = { [weak self] id in self?.signal(id) } + conversation.agentMessageDeliveryChanged = { [weak self] message, state in + guard let self else { return } + if state != .delivered || !self.collaborationStore.confirmsDeliveryDuringMessagePersistence { + try? self.collaborationStore.updateReceipt( + messageID: message.id, state: state, + failure: state == .undelivered ? "The target run ended before delivery." : nil) + } + self.signal(conversationID) + } + reconcileReceipts(for: conversation) + } + + private func attachAll() { conversations.conversations.forEach(configure) } + + private func signal(_ id: UUID) { + guard let conversation = conversations.conversation(id: id), isReady(conversation) else { return } + for (_, waiter) in waiters where waiter.ids.contains(id) { + waiter.continuation.yield() + waiter.continuation.finish() + } + } + + private func makeWaitStream(ids: Set) -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + waiters[id] = (ids, continuation) + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.waiters[id] = nil } + } + } + } + + private func readyChats(_ ids: [UUID]) -> [ConversationModel] { + ids.compactMap { conversations.conversation(id: $0) }.filter(isReady) + } + + private func isReady(_ conversation: ConversationModel) -> Bool { + !conversation.isPreparingAgentRun + && (conversation.runState.isTerminal || conversation.runState == .waitingForUser + || conversation.runState == .waitingForApproval) + } + + private func waitJSON( + ids: [UUID], timedOut: Bool, interrupted: Bool = false + ) -> AgentJSONValue { + .object([ + "timed_out": .bool(timedOut), + "interrupted": .bool(interrupted), + "chats": .array( + ids.compactMap { id in + conversations.conversation(id: id).map { chatJSON($0) } + }), + ]) + } + + private func chatJSON( + _ conversation: ConversationModel, status: String? = nil, receiptID: UUID? = nil + ) -> AgentJSONValue { + var object = chatObject(conversation) + if let status { object["status"] = .string(status) } + if let receiptID { object["receipt_id"] = .string(receiptID.uuidString) } + return .object(object) + } + + private func chatObject(_ conversation: ConversationModel) -> [String: AgentJSONValue] { + [ + "chat_id": .string(conversation.id.uuidString), + "title": .string(conversation.derivedTitle), + "model": .string(conversation.model.flatMap { ai.lookupChat($0)?.model.label } ?? ""), + "status": .string(conversation.isPreparingAgentRun ? "preparing" : conversation.runState.rawValue), + "updated_at": .string(conversation.updatedAt.ISO8601Format()), + "archived": .bool(conversation.isArchived), + ] + } + + private func receiptJSON(_ receipt: AssistantChatDeliveryReceipt) -> AgentJSONValue { + let title = conversations.conversation(id: receipt.targetConversationID)?.derivedTitle ?? "" + return .object([ + "chat_id": .string(receipt.targetConversationID.uuidString), + "title": .string(title), + "status": .string(receipt.state.rawValue), + "receipt_id": .string(receipt.id.uuidString), + ]) + } + + private func existingDelivery(sourceRunID: UUID, sourceToolCallID: String) throws -> AgentJSONValue? { + guard + var receipt = try collaborationStore.receipt( + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID) + else { return nil } + guard let target = conversations.conversation(id: receipt.targetConversationID) else { + if receipt.state == .queued { + receipt.state = .undelivered + receipt.failure = "The target chat no longer exists." + receipt.updatedAt = .now + try collaborationStore.save(receipt) + } + return receiptJSON(receipt) + } + configure(target) + receipt = + try collaborationStore.receipt( + sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID) ?? receipt + return chatJSON(target, status: receipt.state.rawValue, receiptID: receipt.id) + } + + /// A queued reservation survives a crash. Once the conversation is restored, + /// the durable prompt proves delivery; an absent prompt with no live owner proves + /// it can no longer be delivered. + private func reconcileReceipts(for conversation: ConversationModel) { + guard let receipts = try? collaborationStore.receipts(targetConversationID: conversation.id) else { + return + } + let messageIDs = Set(conversation.transcript.map(\.id)).union(conversation.messages.map(\.id)) + for receipt in receipts where receipt.state == .queued { + if messageIDs.contains(receipt.messageID), + !collaborationStore.confirmsDeliveryDuringMessagePersistence + { + try? collaborationStore.updateReceipt( + messageID: receipt.messageID, state: .delivered, failure: nil) + } else if !conversation.isAgentRunActive { + try? collaborationStore.updateReceipt( + messageID: receipt.messageID, state: .undelivered, + failure: "The pending delivery did not survive restart.") + } + } + } + + public static func reference(_ ref: AIModelRef) -> String { + "\(ref.modelProviderID.uuidString)::\(ref.modelID)" + } + + private static func modelReference(_ value: String) -> AIModelRef? { + guard let boundary = value.range(of: "::"), + let providerID = UUID(uuidString: String(value[.. Int { + guard let cursor else { return 0 } + guard let value = Int(cursor), value >= 0, value <= 10_000_000 else { + throw AgentToolError.invalidArguments("The chat cursor is invalid.") + } + return value + } +} + +extension String { + fileprivate var nonempty: String? { isEmpty ? nil : self } +} diff --git a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift index 25185381..64ea8a0c 100644 --- a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift +++ b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift @@ -7,7 +7,10 @@ import TermindDomain @MainActor public final class AssistantExecutionController { public typealias RegistryFactory = - @MainActor (ConversationModel, AIModel, ModelProvider, AgentSkillCatalog, AgentEventChannel, AgentPaneAccess?) + @MainActor ( + ConversationModel, AIModel, ModelProvider, AgentSkillCatalog, + AgentEventChannel, AgentPaneAccess?, AgentChatSourceContext + ) -> AgentToolRegistry private let ai: AIConfigurationModel @@ -82,6 +85,11 @@ public final class AssistantExecutionController { fileprivate var messageID: UUID } + public struct PreparedAgentDelivery { + fileprivate var capture: RunCapture + public var message: AgentTranscriptMessage + } + @discardableResult public func cancelPendingRuns() -> Task { var cancellationTasks: [Task] = [] @@ -309,6 +317,75 @@ public final class AssistantExecutionController { } } + /// Captures the target's own run settings while keeping the incoming agent text + /// outside its composer, attachments, armed skill, and model picker state. + public func prepareIncomingAgentMessage( + _ text: String, origin: AgentMessageOrigin, in conversation: ConversationModel + ) -> PreparedAgentDelivery? { + guard authorize(), canSubmit(to: conversation), !text.isEmpty else { return nil } + let snapshot = contextSnapshot( + for: conversation, context: .none, + terminalSuggestionsAvailable: false) + let prompt = conversation.composeIncomingAgentTurn(text, origin: origin) + guard + let capture = captureRun( + conversation, prompt: prompt, contextSnapshot: snapshot, + beginPreparation: true, panes: nil) + else { return nil } + let message = AgentTranscriptMessage( + id: prompt.id, role: .user, text: prompt.text, + authoredText: nil, contextSnapshot: snapshot, origin: origin) + return PreparedAgentDelivery(capture: capture, message: message) + } + + /// Persists the incoming prompt as a staging run before the coordinator accepts + /// delivery. The real run adopts this row when it starts. + public func persistPreparedAgentDelivery( + _ prepared: PreparedAgentDelivery, in conversation: ConversationModel + ) async throws { + let capture = prepared.capture + guard isCurrent(capture, for: conversation) else { throw CancellationError() } + try await agentRunRepository.save( + AgentRunSnapshot( + id: prepared.message.id, conversationID: conversation.id, + state: .completed, permissionMode: capture.permissionMode, + messages: [prepared.message], startedAt: prepared.message.createdAt, + finishedAt: prepared.message.createdAt)) + // The prompt save is the acceptance boundary and, for durable stores, + // atomically confirms its delivery receipt. Metadata is useful but not + // allowed to turn that accepted message into an orphan outside the live + // library when a later write fails. + try? await agentRunRepository.setConversationMetadata( + conversation.metadata, conversationID: conversation.id) + } + + public func startPreparedAgentDelivery( + _ prepared: PreparedAgentDelivery, in conversation: ConversationModel + ) { + let capture = prepared.capture + guard isCurrent(capture, for: conversation) else { return } + guard + let prompt = conversation.appendIncomingAgentTurnContent( + capture.prompt, contextSnapshot: capture.contextSnapshot) + else { + Task { await cancelPreparedAgentDelivery(prepared, in: conversation) } + return + } + var submitted = capture + submitted.prompt = prompt + startPreparation(submitted, for: conversation, compactBeforeRun: true) + } + + public func cancelPreparedAgentDelivery( + _ prepared: PreparedAgentDelivery, in conversation: ConversationModel + ) async { + guard isCurrent(prepared.capture, for: conversation) else { return } + await requestCancellation( + for: conversation, expectedID: prepared.capture.id, + recordsInterruption: false + ).value + } + private func captureDraft( _ conversation: ConversationModel, context: SessionContext, terminalSuggestionsAvailable: Bool, panes: AgentPaneAccess? @@ -349,7 +426,8 @@ public final class AssistantExecutionController { text: prompt ?? lastUserTurn?.text ?? "", authoredText: authoredPrompt ?? lastUserTurn?.authoredText ?? "", images: promptImages ?? lastUserTurn?.images ?? [], - contextAttachments: promptContextAttachments ?? lastUserTurn?.contextAttachments ?? []) + contextAttachments: promptContextAttachments ?? lastUserTurn?.contextAttachments ?? [], + origin: lastUserTurn?.origin) guard !resolvedPrompt.text.isEmpty || !resolvedPrompt.images.isEmpty || !resolvedPrompt.contextAttachments.isEmpty @@ -511,7 +589,8 @@ public final class AssistantExecutionController { request: AgentRunRequest( conversationID: conversation.id, promptID: capture.prompt.id, prompt: capture.prompt.text, - authoredPrompt: capture.prompt.authoredText, + authoredPrompt: capture.prompt.origin == nil ? capture.prompt.authoredText : nil, + promptOrigin: capture.prompt.origin, promptImages: capture.prompt.images, promptContextAttachments: capture.prompt.contextAttachments, permissionMode: capture.permissionMode, isPlanning: isPlanning, @@ -570,7 +649,15 @@ public final class AssistantExecutionController { let channel = chat != nil && secret != nil ? AgentEventChannel(repository: agentRunRepository) : nil let baseRegistry = channel.flatMap { channel in chat.map { - makeRegistry(conversation, $0.model, $0.provider, skillCatalog, channel, panes) + let modelReference = AIModelRef( + modelProviderID: $0.provider.id, modelID: $0.model.id) + return makeRegistry( + conversation, $0.model, $0.provider, skillCatalog, channel, panes, + AgentChatSourceContext( + conversationID: conversation.id, title: conversation.derivedTitle, + modelReference: AssistantChatCoordinator.reference(modelReference), + permissionMode: permissionMode, + mode: conversation.isPlanning ? .planning : .acting)) } } let guardian: GuardianCapture? = { diff --git a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift index 31a808d2..39f83737 100644 --- a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift +++ b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift @@ -88,6 +88,7 @@ public final class AssistantConversationStore { contextAttachments: message.contextAttachments ), images: message.images, + origin: message.origin, createdAt: message.createdAt ) case .assistant: diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 80c45609..f831436a 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -285,12 +285,14 @@ nonisolated public struct ChatMessage: Identifiable, Hashable, Sendable { /// Restored conversations carry their stored time so history does not /// re-sort itself behind every card the moment a window opens. public var createdAt: Date = .now + /// App-authored provenance for an incoming agent message. + public var origin: AgentMessageOrigin? public init( id: UUID = UUID(), role: Role, text: String, reasoning: [AgentReasoningBlock] = [], contextChips: [ContextChipReference] = [], images: [AgentImageAttachment] = [], commands: [SuggestedCommand] = [], retryProgress: AgentModelRetryProgress? = nil, - isStreaming: Bool = false, createdAt: Date = .now + isStreaming: Bool = false, origin: AgentMessageOrigin? = nil, createdAt: Date = .now ) { self.id = id self.role = role @@ -301,10 +303,15 @@ nonisolated public struct ChatMessage: Identifiable, Hashable, Sendable { self.commands = commands self.retryProgress = retryProgress self.isStreaming = isStreaming + self.origin = origin self.createdAt = createdAt } } +nonisolated public enum AgentMessageDeliveryState: String, Hashable, Sendable { + case queued, delivered, undelivered +} + nonisolated public struct AgentToolCard: Identifiable, Hashable, Sendable { public nonisolated enum State: String, Hashable, Sendable { case proposed, waitingForApproval, waitingForUser, reviewing, running, completed, denied, failed @@ -899,6 +906,11 @@ public final class ConversationModel: Identifiable { public var isAgentRunActive: Bool { isStreaming || isPreparingAgentRun } + /// The posture captured by the active run. The UI toggle remains editable while + /// a run is executing, so collaboration policy must use the frozen request until + /// that run releases ownership. + public var effectiveIsPlanning: Bool { activeRunOwner?.request.isPlanning ?? isPlanning } + /// The current task list, whole. Replaced rather than patched — see /// `AgentTask`. public private(set) var tasks: [AgentTask] = [] @@ -1009,6 +1021,7 @@ public final class ConversationModel: Identifiable { /// Independent app notification events, separate from title and Memory callbacks. @ObservationIgnored public var notificationEvent: ((AssistantNotificationEvent) -> Void)? + @ObservationIgnored public var chatStateDidChange: ((UUID) -> Void)? @ObservationIgnored private var lastNotificationInteraction: AssistantNotificationInteraction? private var unreadNotificationIDs = Set() public var unreadNotificationCount: Int { unreadNotificationIDs.count } @@ -1020,6 +1033,7 @@ public final class ConversationModel: Identifiable { func removeUnreadNotification(_ id: String) { unreadNotificationIDs.remove(id) } private func publishNotificationInteraction() { + chatStateDidChange?(id) let current = notificationInteraction guard current != lastNotificationInteraction else { return } lastNotificationInteraction = current @@ -1032,6 +1046,9 @@ public final class ConversationModel: Identifiable { public var memoryUserAccepted: ((AgentTranscriptMessage, [AgentTranscriptMessage]) -> Void)? public var memoryRunFinished: ((AgentRunSummary, [UUID], [MemoryLearningEvent], [AgentTranscriptMessage]) -> Void)? public var memoryToolBatchFinished: (([MemoryLearningEvent], [AgentTranscriptMessage]) -> Void)? + /// Durable delivery ownership stays in the app coordinator; the conversation reports + /// boundary transitions without knowing how receipts are stored. + public var agentMessageDeliveryChanged: ((AgentTranscriptMessage, AgentMessageDeliveryState) -> Void)? private var startedMemoryTools: [UUID: [UUID: UUID]] = [:] private var finishedMemoryTools: [UUID: [UUID: UUID]] = [:] @@ -1117,7 +1134,65 @@ public final class ConversationModel: Identifiable { guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging, !isPersistingAgentPreparationFailure else { return false } - return transcript.contains { $0.id == id && $0.role == .user && !$0.isCompaction } + guard + let target = transcript.firstIndex(where: { + $0.id == id && $0.role == .user && !$0.isCompaction && $0.origin == nil + }) + else { return false } + return !removedSuffixContainsToolActivity(from: target) + } + + /// The edit operation removes this entire suffix. Any tool lifecycle in that scope + /// makes the user turn immutable, including proposed, denied, failed, and read-only calls. + private func removedSuffixContainsToolActivity(from target: Int) -> Bool { + let original = transcript[target] + let removed = Array(transcript[target...]) + if removed.contains(where: { + $0.role == .tool || !$0.toolCalls.isEmpty || $0.toolCallID != nil || $0.toolName != nil + }) { + return true + } + + let visibleTarget = messages.firstIndex(where: { $0.id == original.id }) + let visiblePrefix = visibleTarget.map { Array(messages[..<$0]) } ?? [] + let visibleRemoved = visibleTarget.map { Array(messages[$0...]) } ?? [] + let removedMessageIDs = Set(removed.map(\.id)).union(visibleRemoved.map(\.id)) + let keptMessageIDs = Set(transcript[..= original.createdAt + } } public func canForkAssistantMessage(_ id: UUID) -> Bool { @@ -1337,7 +1412,8 @@ public final class ConversationModel: Identifiable { contextChips: ContextChip.visibleReferences( in: message.text, images: message.images, contextAttachments: message.contextAttachments - ), images: message.images, createdAt: message.createdAt + ), images: message.images, origin: message.origin, + createdAt: message.createdAt ) case .assistant: guard !message.text.isEmpty || !message.reasoning.isEmpty else { return nil } @@ -1933,16 +2009,18 @@ public final class ConversationModel: Identifiable { public var authoredText: String public var images: [AgentImageAttachment] public var contextAttachments: [AgentContextAttachment] + public var origin: AgentMessageOrigin? public init( id: UUID, text: String, authoredText: String, images: [AgentImageAttachment], - contextAttachments: [AgentContextAttachment] + contextAttachments: [AgentContextAttachment], origin: AgentMessageOrigin? = nil ) { self.id = id self.text = text self.authoredText = authoredText self.images = images self.contextAttachments = contextAttachments + self.origin = origin } } @@ -1970,14 +2048,47 @@ public final class ConversationModel: Identifiable { messages.append( ChatMessage( id: messageID, role: .user, text: composed.authoredText, - contextChips: contextChips, images: composed.images + contextChips: contextChips, images: composed.images, + origin: composed.origin ) ) transcript.append( AgentTranscriptMessage( id: messageID, role: .user, text: composed.text, - authoredText: composed.authoredText, images: composed.images, - contextAttachments: composed.contextAttachments, contextSnapshot: contextSnapshot + authoredText: composed.origin == nil ? composed.authoredText : nil, + images: composed.images, + contextAttachments: composed.contextAttachments, contextSnapshot: contextSnapshot, + origin: composed.origin + ) + ) + if let message = transcript.last { + contextEstimateTracker.upsert(message) + publishContextEstimateImmediately() + } + updatedAt = .now + return composed + } + + /// Appends an incoming agent turn without reading or mutating any composer state. + /// Routed messages never inherit the target's pending chips or armed skill. + @discardableResult + public func appendIncomingAgentTurnContent( + _ composed: ComposedUserTurn, contextSnapshot: AgentTurnContextSnapshot? = nil + ) -> ComposedUserTurn? { + guard composed.origin != nil, composed.images.isEmpty, + composed.contextAttachments.isEmpty + else { return nil } + messages.append( + ChatMessage( + id: composed.id, role: .user, text: composed.authoredText, + contextChips: [], images: [], origin: composed.origin + ) + ) + transcript.append( + AgentTranscriptMessage( + id: composed.id, role: .user, text: composed.text, + authoredText: nil, contextSnapshot: contextSnapshot, + origin: composed.origin ) ) if let message = transcript.last { @@ -2032,6 +2143,15 @@ public final class ConversationModel: Identifiable { ) } + /// Builds a turn delivered by another agent without touching composer state. + public func composeIncomingAgentTurn( + _ text: String, origin: AgentMessageOrigin + ) -> ComposedUserTurn { + ComposedUserTurn( + id: UUID(), text: text, authoredText: text, images: [], + contextAttachments: [], origin: origin) + } + /// The history to replay for a run whose prompt is the last user turn. public var pendingPriorMessages: [AgentTranscriptMessage] { guard let last = transcript.lastIndex(where: { $0.role == .user }), @@ -2053,6 +2173,7 @@ public final class ConversationModel: Identifiable { agentPreparationID = id cancelAgentPreparation = cancellation isPreparingAgentRun = true + chatStateDidChange?(self.id) return true } @@ -2062,6 +2183,7 @@ public final class ConversationModel: Identifiable { agentPreparationID = id cancelAgentPreparation = cancellation isPreparingAgentRun = true + chatStateDidChange?(self.id) } func finishAgentPreparation(id: UUID) { @@ -2070,11 +2192,13 @@ public final class ConversationModel: Identifiable { cancelAgentPreparation = nil isPreparingAgentRun = false isPersistingAgentPreparationFailure = false + chatStateDidChange?(self.id) } func beginAgentPreparationFailurePersistence(id: UUID) { guard agentPreparationID == id else { return } isPersistingAgentPreparationFailure = true + chatStateDidChange?(self.id) } @discardableResult @@ -2154,6 +2278,7 @@ public final class ConversationModel: Identifiable { authoredText: request.authoredPrompt, images: request.promptImages, contextAttachments: request.promptContextAttachments, contextSnapshot: request.contextSnapshot, + origin: request.promptOrigin, createdAt: transcript.first(where: { $0.id == request.promptID })?.createdAt ?? owner.startedAt ) @@ -2381,6 +2506,27 @@ public final class ConversationModel: Identifiable { } } + /// Queues an app-routed agent message without reading or changing the target composer. + @discardableResult + public func submitIncomingAgentSteering(_ message: AgentTranscriptMessage) -> Bool { + guard message.role == .user, message.origin != nil, message.authoredText == nil, + let owner = activeRunOwner, !isArchived, !isCompacting, !isHistoryChanging, + !isPreparingAgentRun + else { return false } + let pending = PendingSteeringMessage(draft: message.text, message: message) + pendingSteering.append(pending) + agentMessageDeliveryChanged?(message, .queued) + let runtime = owner.runtime + Task { [weak self] in + guard await runtime.steer(message) else { + guard self?.activeRunOwner === owner else { return } + self?.returnAllSteeringToDraft() + return + } + } + return true + } + /// Hands one queued message back to the composer. /// /// Called by the ✕ on a queued row, and by every path that ends a run with @@ -2389,6 +2535,10 @@ public final class ConversationModel: Identifiable { public func returnSteeringToDraft(_ id: AgentTranscriptMessage.ID) { guard let index = pendingSteering.firstIndex(where: { $0.id == id }) else { return } let pending = pendingSteering.remove(at: index) + if pending.message.origin != nil { + agentMessageDeliveryChanged?(pending.message, .undelivered) + return + } composer.draft = [pending.draft, composer.draft] .filter { !$0.isEmpty }.joined(separator: "\n\n") for chip in pending.attachments { attach(chip) } @@ -2879,7 +3029,9 @@ public final class ConversationModel: Identifiable { runSummaries[run.id] = run let userIDs = run.state == .completed - ? transcript.filter { $0.role == .user && messageRunIDs[$0.id] == run.id }.map(\.id) : [] + ? transcript.filter { + $0.role == .user && $0.origin == nil && messageRunIDs[$0.id] == run.id + }.map(\.id) : [] let tools = (startedMemoryTools.removeValue(forKey: run.id) ?? [:]).map { MemoryLearningEvent(id: $0.key, sourceMessageID: $0.value, kind: .tool) } @@ -2975,9 +3127,11 @@ public final class ConversationModel: Identifiable { ChatMessage( id: message.id, role: .user, text: visibleText, contextChips: contextChips, images: message.images, + origin: message.origin, createdAt: message.createdAt )) transcript.append(message) + if message.origin != nil { agentMessageDeliveryChanged?(message, .delivered) } memoryUserAccepted?(message, transcript) contextEstimateTracker.upsert(message) publishContextEstimateImmediately() diff --git a/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift b/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift index 315de763..24e8299a 100644 --- a/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift @@ -340,7 +340,7 @@ public final class MemoryModel { model: any AgentModelCompleting, sessionToken: UUID? = nil, historyEpoch: UUID? = nil ) { let authored = message.authoredText ?? ContextChip.visiblePrompt(in: message.text) - guard isEnabled, !isSuspended, reviewEnabled, + guard message.origin == nil, isEnabled, !isSuspended, reviewEnabled, sessionToken == nil || sessionToken == generation, historyEpoch == nil || historyEpoch == learningEpoch(for: conversationID), Self.looksLikeCorrection(authored), !queuedCorrectionIDs.contains(message.id), diff --git a/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift b/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift new file mode 100644 index 00000000..d78baa4e --- /dev/null +++ b/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift @@ -0,0 +1,178 @@ +import AgentKit +import Foundation +import SwiftData +import TermindApplication + +/// Additive sidecar; the historical message model remains frozen. +@Model +public final class PersistentAgentMessageOrigin { + @Attribute(.unique) public var messageID: UUID = UUID() + public var sourceConversationID: UUID = UUID() + public var sourceRunID: UUID = UUID() + public var sourceToolCallID = "" + public var sourceTitle: String? + + public init(messageID: UUID, origin: AgentMessageOrigin) { + self.messageID = messageID + sourceConversationID = origin.sourceConversationID + sourceRunID = origin.sourceRunID + sourceToolCallID = origin.sourceToolCallID + sourceTitle = origin.sourceTitle.map(AgentPersistenceRedactor.visibleText) + } + + public var value: AgentMessageOrigin { + AgentMessageOrigin( + sourceConversationID: sourceConversationID, sourceRunID: sourceRunID, + sourceToolCallID: sourceToolCallID, sourceTitle: sourceTitle) + } +} + +@Model +public final class PersistentAgentConversationOrigin { + @Attribute(.unique) public var conversationID: UUID = UUID() + public var sourceConversationID: UUID = UUID() + public var sourceRunID: UUID = UUID() + public var sourceToolCallID = "" + + public init(_ value: AssistantChatCreationRelation) { + conversationID = value.conversationID + sourceConversationID = value.sourceConversationID + sourceRunID = value.sourceRunID + sourceToolCallID = value.sourceToolCallID + } +} + +@Model +public final class PersistentAgentDeliveryReceipt { + @Attribute(.unique) public var id: UUID = UUID() + /// Unique source-run/tool-call key prevents retries from duplicating delivery. + @Attribute(.unique) public var dedupeKey = "" + public var sourceConversationID: UUID = UUID() + public var targetConversationID: UUID = UUID() + public var sourceRunID: UUID = UUID() + public var sourceToolCallID = "" + public var messageID: UUID = UUID() + public var state = AgentMessageDeliveryState.queued.rawValue + public var createdAt: Date = Date() + public var updatedAt: Date = Date() + public var failure: String? + + public init(_ value: AssistantChatDeliveryReceipt) { + id = value.id + dedupeKey = Self.key(runID: value.sourceRunID, toolCallID: value.sourceToolCallID) + update(value) + } + + public func update(_ value: AssistantChatDeliveryReceipt) { + let preservesDelivered = + state == AgentMessageDeliveryState.delivered.rawValue + && value.state != .delivered + sourceConversationID = value.sourceConversationID + targetConversationID = value.targetConversationID + sourceRunID = value.sourceRunID + sourceToolCallID = value.sourceToolCallID + messageID = value.messageID + if !preservesDelivered { state = value.state.rawValue } + createdAt = value.createdAt + if !preservesDelivered { + updatedAt = value.updatedAt + failure = value.failure.map(AgentPersistenceRedactor.visibleText) + } + } + + public var value: AssistantChatDeliveryReceipt? { + guard let state = AgentMessageDeliveryState(rawValue: state) else { return nil } + return AssistantChatDeliveryReceipt( + id: id, sourceConversationID: sourceConversationID, + targetConversationID: targetConversationID, sourceRunID: sourceRunID, + sourceToolCallID: sourceToolCallID, messageID: messageID, state: state, + createdAt: createdAt, updatedAt: updatedAt, failure: failure) + } + + public static func key(runID: UUID, toolCallID: String) -> String { + "\(runID.uuidString.lowercased())\u{0}\(toolCallID)" + } +} + +@MainActor +public final class SwiftDataAssistantChatCollaborationStore: AssistantChatCollaborationStoring { + private let container: ModelContainer + + public init(context: ModelContext) { container = context.container } + + public var confirmsDeliveryDuringMessagePersistence: Bool { true } + + public func receipt(sourceRunID: UUID, sourceToolCallID: String) throws + -> AssistantChatDeliveryReceipt? + { + let context = ModelContext(container) + let key = PersistentAgentDeliveryReceipt.key( + runID: sourceRunID, toolCallID: sourceToolCallID) + return try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.dedupeKey == key }) + ).first?.value + } + + public func save(_ receipt: AssistantChatDeliveryReceipt) throws { + let context = ModelContext(container) + let key = PersistentAgentDeliveryReceipt.key( + runID: receipt.sourceRunID, toolCallID: receipt.sourceToolCallID) + if let row = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.dedupeKey == key }) + ).first { + row.update(receipt) + } else { + context.insert(PersistentAgentDeliveryReceipt(receipt)) + } + try context.savePendingChanges() + } + + public func updateReceipt( + messageID: UUID, state: AgentMessageDeliveryState, failure: String? + ) throws { + let context = ModelContext(container) + let rows = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.messageID == messageID })) + for row in rows where row.state != AgentMessageDeliveryState.delivered.rawValue || state == .delivered { + row.state = state.rawValue + row.failure = failure.map(AgentPersistenceRedactor.visibleText) + row.updatedAt = .now + } + if !rows.isEmpty { try context.savePendingChanges() } + } + + public func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] { + let context = ModelContext(container) + return try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.targetConversationID == targetConversationID }, + sortBy: [SortDescriptor(\.createdAt)]) + ) + .compactMap(\.value) + } + + public func save(_ relation: AssistantChatCreationRelation) throws { + let context = ModelContext(container) + let id = relation.conversationID + guard + try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.conversationID == id }) + ).isEmpty + else { return } + context.insert(PersistentAgentConversationOrigin(relation)) + try context.savePendingChanges() + } + + public func deleteRelation(conversationID: UUID) throws { + let context = ModelContext(container) + let rows = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.conversationID == conversationID })) + for row in rows { context.delete(row) } + if !rows.isEmpty { try context.savePendingChanges() } + } +} diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index af0a1846..14d5e361 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -862,6 +862,19 @@ public enum TermindSchemaV41: VersionedSchema { } } +/// V42 adds agent-message provenance, chat creation relations, and durable +/// collaboration delivery receipts without changing any historical model shape. +public enum TermindSchemaV42: VersionedSchema { + public static let versionIdentifier = Schema.Version(42, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV41.models + [ + PersistentAgentMessageOrigin.self, + PersistentAgentConversationOrigin.self, + PersistentAgentDeliveryReceipt.self, + ] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -876,6 +889,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV30.self, TermindSchemaV31.self, TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self, TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self, + TermindSchemaV42.self, ] } @@ -976,6 +990,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV38.self, toVersion: TermindSchemaV39.self), MigrationStage.lightweight(fromVersion: TermindSchemaV39.self, toVersion: TermindSchemaV40.self), MigrationStage.lightweight(fromVersion: TermindSchemaV40.self, toVersion: TermindSchemaV41.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV41.self, toVersion: TermindSchemaV42.self), ] } } diff --git a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift index f9916951..3a26e268 100644 --- a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift @@ -1,6 +1,7 @@ import AgentKit import Foundation import SwiftData +import TermindApplication @MainActor public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { @@ -100,6 +101,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { storedByID[message.id] = row insertContextAttachments(for: message) inserted = true + try insertOrigin(for: message) } // A user row may already exist (editing or interrupted preparation) before // the run freezes its context. Add its backing once without rewriting history. @@ -204,6 +206,9 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { let replayByMessage = Dictionary( uniqueKeysWithValues: try context.fetch(FetchDescriptor()).map { ($0.messageID, $0) }) + let originByMessage = Dictionary( + uniqueKeysWithValues: + try context.fetch(FetchDescriptor()).map { ($0.messageID, $0.value) }) let compactionRows = try context.fetch(FetchDescriptor()) let attachmentRows = try context.fetch( FetchDescriptor() @@ -252,6 +257,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { isError: row.isError, isCompaction: row.isCompaction, contextSnapshot: replayByMessage[row.id]?.contextSnapshot, modelText: replayByMessage[row.id]?.modelText, + origin: originByMessage[row.id], createdAt: row.createdAt ) } @@ -436,6 +442,31 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { let laterRuns = Array(runs.dropFirst(targetRunIndex + 1)) let laterRunIDs = Set(laterRuns.map(\.id)) let keptTargetMessages = keptMessages.filter { $0.runID == target.runID } + let originRows = try context.fetch(FetchDescriptor()) + guard !originRows.contains(where: { $0.messageID == target.id }) else { + throw AgentConversationPersistenceError.messageNotFound + } + let keptCallIDs = Set(keptMessages.flatMap(Self.toolCallIDs)) + let removedCallIDs = Set(removedMessages.flatMap(Self.toolCallIDs)) + let removedHasToolMessages = removedMessages.contains { + $0.role == AgentTranscriptRole.tool.rawValue || $0.toolCallID != nil + || $0.toolName != nil || !Self.toolCallIDs($0).isEmpty + } + let invocations = try context.fetch(FetchDescriptor()) + let removedHasToolCards = invocations.contains { invocation in + if laterRunIDs.contains(invocation.runID) { return true } + guard invocation.runID == target.runID else { return false } + if let source = invocation.sourceMessageID { + if removedMessageIDs.contains(source) { return true } + if keptMessages.contains(where: { $0.id == source }) { return false } + } + if removedCallIDs.contains(invocation.callID) { return true } + if keptCallIDs.contains(invocation.callID) { return false } + return invocation.createdAt >= target.createdAt + } + guard !removedHasToolMessages, !removedHasToolCards else { + throw AgentConversationPersistenceError.editWouldRemoveTools + } let removesTargetRun = keptTargetMessages.isEmpty let runsToDelete = removesTargetRun ? [runs[targetRunIndex]] + laterRuns : laterRuns let runIDsToDelete = Set(runsToDelete.map(\.id)) @@ -491,6 +522,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { )) insertContextAttachments(for: replacement) insertReplay(for: replacement) + try insertOrigin(for: replacement) let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == id } @@ -570,6 +602,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { )) insertContextAttachments(for: message) insertReplay(for: message) + try insertOrigin(for: message) } for record in value.compactions { context.insert(PersistentAgentCompaction(record, conversationID: value.id)) @@ -628,6 +661,8 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { where messageIDs.contains(row.messageID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } + for row in try context.fetch(FetchDescriptor()) + where messageIDs.contains(row.messageID) { context.delete(row) } for row in messages { context.delete(row) } for row in runs { context.delete(row) } } @@ -638,6 +673,8 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { where messageIDs.contains(row.messageID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } + for row in try context.fetch(FetchDescriptor()) + where messageIDs.contains(row.messageID) { context.delete(row) } for row in messages { context.delete(row) } } @@ -731,6 +768,20 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { )) } + private func insertOrigin(for message: AgentTranscriptMessage) throws { + guard let origin = message.origin else { return } + context.insert(PersistentAgentMessageOrigin(messageID: message.id, origin: origin)) + let messageID = message.id + let receipts = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.messageID == messageID })) + for receipt in receipts where receipt.state == AgentMessageDeliveryState.queued.rawValue { + receipt.state = AgentMessageDeliveryState.delivered.rawValue + receipt.failure = nil + receipt.updatedAt = .now + } + } + /// Puts back tool results that were recorded but never made it into the /// transcript. /// diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 17345fb9..da4eef1e 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -6,7 +6,7 @@ import SwiftData @MainActor public enum TermindPersistence { // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV41.self) + public static let schema = Schema(versionedSchema: TermindSchemaV42.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPresentation/AgentToolPresentation.swift b/Frameworks/TermindPresentation/AgentToolPresentation.swift index f44580fc..5e3b9619 100644 --- a/Frameworks/TermindPresentation/AgentToolPresentation.swift +++ b/Frameworks/TermindPresentation/AgentToolPresentation.swift @@ -12,6 +12,10 @@ nonisolated public enum AgentToolPresentation { public static let displayCharacterLimit = 8_192 public static let displayLineLimit = 120 + private static let chatCollaborationToolNames: Set = [ + "list_models", "create_new_chat", "list_chats", "read_chat", "send_to_chat", "wait_chats", + ] + nonisolated public struct OutputPreview: Equatable, Sendable { public let displayed: String public let full: String @@ -20,6 +24,7 @@ nonisolated public enum AgentToolPresentation { } public static func usesCompactDetails(for descriptor: AgentToolDescriptor) -> Bool { + if isChatCollaborationTool(descriptor) { return true } guard let kind = AgentKnowledgeToolPresentation.kind(for: descriptor) else { return false } return kind != .legacySkill } @@ -59,7 +64,9 @@ nonisolated public enum AgentToolPresentation { detail: AgentToolDetail( items: approvalArgumentItems( request.invocation.call.arguments, presenter: presenter, - hidesHostID: hostName != nil, locale: locale) + hidesHostID: hostName != nil, + hidesSensitiveChatArguments: isChatCollaborationTool(request.descriptor), + locale: locale) ) ) } @@ -366,6 +373,32 @@ nonisolated public enum AgentToolPresentation { ]) } + if isChatCollaborationTool(card.descriptor) { + let fallback = AgentToolDetail(items: [ + .message( + AppLocalization.resolve(AppStrings.Assistant.chatDetailsUnavailable, locale: locale), + result.isError ? .failure : .secondary) + ]) + if result.isError || card.state == .failed || card.state == .denied || card.state == .interrupted { + guard + detailOutput( + from: result, presentation: card.descriptor.presentation, + review: card.review) != nil + else { return nil } + return fallback + } + let raw = result.untrustedPayload ?? result.content + guard let value = try? AgentJSONValue.decode(Data(raw.utf8)), let presenter else { + return fallback + } + return AgentToolDetail( + items: presenter.present( + .init( + result: value, + arguments: card.invocation.call.arguments.objectValue ?? [:], + locale: locale))) + } + // MCP output is remote-defined and intentionally keeps the established // JSON presentation. Built-ins below never fall back to JSON syntax. if card.descriptor.target == .mcp { @@ -487,8 +520,16 @@ nonisolated public enum AgentToolPresentation { _ arguments: AgentJSONValue, presenter: AgentToolDetailPresenter?, hidesHostID: Bool, + hidesSensitiveChatArguments: Bool, locale: Locale ) -> [AgentToolDetail.Item] { + if hidesSensitiveChatArguments { + guard let object = arguments.objectValue, + let projected = presenter?.presentArguments( + .init(arguments: object, locale: locale)) + else { return [] } + return projected + } guard let object = arguments.objectValue else { return [ .text( @@ -510,6 +551,10 @@ nonisolated public enum AgentToolPresentation { return AgentToolDetailFormatting.genericItems(.object(visible), locale: locale) } + private static func isChatCollaborationTool(_ descriptor: AgentToolDescriptor) -> Bool { + descriptor.target == .local && chatCollaborationToolNames.contains(descriptor.name) + } + private static func renderedPreview( _ full: String, headEnd: String.Index, tailStart: String.Index ) -> String { diff --git a/Frameworks/TermindPresentation/AssistantToolDetails.swift b/Frameworks/TermindPresentation/AssistantToolDetails.swift index 6b20ea81..d07ba9c8 100644 --- a/Frameworks/TermindPresentation/AssistantToolDetails.swift +++ b/Frameworks/TermindPresentation/AssistantToolDetails.swift @@ -1,12 +1,26 @@ import AgentKit import SwiftUI +private struct AssistantToolNavigationKey: EnvironmentKey { + static let defaultValue: (@MainActor (AgentToolDetail.NavigationReference) -> Void)? = nil +} + +extension EnvironmentValues { + /// App-owned navigation for trusted references emitted by built-in tool presenters. + public var assistantToolNavigation: (@MainActor (AgentToolDetail.NavigationReference) -> Void)? + { + get { self[AssistantToolNavigationKey.self] } + set { self[AssistantToolNavigationKey.self] = newValue } + } +} + public struct AssistantToolDetails: View { private let items: [AgentToolDetail.Item] private let isError: Bool private let copy: ((String) -> Void)? private let shortensText: Bool private let textColor: Color + @Environment(\.assistantToolNavigation) private var navigate public init( items: [AgentToolDetail.Item], isError: Bool = false, @@ -31,7 +45,12 @@ public struct AssistantToolDetails: View { case .field(let field): HStack(alignment: .firstTextBaseline, spacing: 8) { Text(field.label).font(.caption.weight(.medium)).foregroundStyle(.secondary) - if let url = allowedURL(field.url) { + if let navigation = field.navigation, let navigate { + Button(field.value) { navigate(navigation) } + .font(field.isMonospaced ? .caption.monospaced() : .caption) + .buttonStyle(.plain) + .foregroundStyle(.tint) + } else if let url = allowedURL(field.url) { Link(field.value, destination: url).font(.caption) } else { Text(field.value).font(field.isMonospaced ? .caption.monospaced() : .caption) @@ -79,7 +98,9 @@ public struct AssistantToolDetails: View { if let subtitle = row.subtitle { Text(subtitle).font(.caption).foregroundStyle(.secondary) } if let detail = row.detail { Text(detail).font(.caption.monospaced()) } if !row.badges.isEmpty { Text(row.badges.joined(separator: " · ")).font(.caption) } - if let url = allowedURL(row.url) { + if let navigation = row.navigation, let navigate { + Button(AppStrings.Common.open.swiftUIKey) { navigate(navigation) } + } else if let url = allowedURL(row.url) { Link(AppStrings.Common.open.swiftUIKey, destination: url) } } diff --git a/Frameworks/TermindPresentation/Strings/Assistant.swift b/Frameworks/TermindPresentation/Strings/Assistant.swift index 4efabe17..f7d71a26 100644 --- a/Frameworks/TermindPresentation/Strings/Assistant.swift +++ b/Frameworks/TermindPresentation/Strings/Assistant.swift @@ -35,6 +35,18 @@ extension AppStrings { "Agent error" } + public static var agent: LocalizedStringResource { + "Agent" + } + + public static func agentFrom(_ title: String) -> LocalizedStringResource { + "Agent from \(title)" + } + + public static var chatDetailsUnavailable: LocalizedStringResource { + "Chat details unavailable" + } + public static var allowCameraAccessInSettingsToTakeAPhoto: LocalizedStringResource { "Allow camera access in Settings to take a photo." } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift index 642e21fd..c0f94e6c 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift @@ -12,7 +12,10 @@ public enum AssistantToolRegistry { ai: AIConfigurationModel, hosts: AgentHostResolver, metrics: any MetricsSnapshotReading, connections: AgentConnectionPool, authentication: AgentSSHAuthenticationRegistry, background: AgentBackgroundCommands, - panes: AgentPaneAccess? = nil + panes: AgentPaneAccess? = nil, + chatModels: (any AgentChatModelCatalog)? = nil, + chats: (any AgentChatCoordinating)? = nil, + chatSource: AgentChatSourceContext? = nil ) -> AgentToolRegistry { let native = ai.webSearch.isEnabled && ai.webSearch.prefersBuiltIn && provider.supportsNativeWebSearch(model: model) @@ -52,6 +55,7 @@ public enum AssistantToolRegistry { tasks: AgentTaskList { channel.emit(.tasksUpdated($0)) }, plans: AgentPlanRecorder { channel.emit(.planPresented($0)) }, skills: skillCatalog, + chatModels: chatModels, chats: chats, chatSource: chatSource, mcpServers: servers, mcpClient: MCPClient(clientInfo: TermindAgentPolicy.mcpClientInfo) ), diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 622f49cf..8b3bc229 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -305,6 +305,8 @@ final class MobileAppModel { base: SwiftDataAgentRunRepository(context: ModelContext(container)), memory: memory), memory: memory, modelSelectionStore: SwiftDataAssistantModelSelectionStore(context: ModelContext(container)), + chatCollaborationStore: SwiftDataAssistantChatCollaborationStore( + context: ModelContext(container)), hostServices: AgentHostServices.live( store: library, coordinator: connectionCoordinator, diff --git a/TFI/Application/Agent/MobileAssistantModel.swift b/TFI/Application/Agent/MobileAssistantModel.swift index e3de8f99..987fcca5 100644 --- a/TFI/Application/Agent/MobileAssistantModel.swift +++ b/TFI/Application/Agent/MobileAssistantModel.swift @@ -6,6 +6,12 @@ import TermindApplication import TermindDomain import TermindRemoteInfrastructure +@MainActor +@Observable +private final class MobileAssistantSuspensionGate { + var isSuspending = false +} + /// Application-owned Assistant runs and services. Scene-local navigation lives in /// `MobileAssistantSceneModel`; only the conversation store is shared here. @MainActor @@ -21,6 +27,7 @@ final class MobileAssistantModel { private let repository: any AgentRunPersisting fileprivate let modelSelections: AssistantModelSelectionController private let operations: AssistantConversationOperations + private let chatCoordinator: AssistantChatCoordinator var observeNotifications: ((ConversationModel) -> Void)? { didSet { execution.observeNotifications = observeNotifications } } @@ -30,8 +37,9 @@ final class MobileAssistantModel { private let attachmentLoader: ContextAttachmentLoading private var loadTask: Task? private var suspensionTask: Task? + private let suspensionGate: MobileAssistantSuspensionGate private var loaded = false - private(set) var isSuspending = false + var isSuspending: Bool { suspensionGate.isSuspending } var error: String? var isRunning: Bool { store.conversations.contains { $0.isStreaming } } @@ -47,6 +55,7 @@ final class MobileAssistantModel { init( configuration: AIConfigurationModel, repository: any AgentRunPersisting, memory: MemoryModel? = nil, modelSelectionStore: any AssistantModelSelectionStoring = InMemoryAssistantModelSelectionStore(), + chatCollaborationStore: any AssistantChatCollaborationStoring = InMemoryAssistantChatCollaborationStore(), hostServices: AgentHostServices, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel, authorize: @escaping @MainActor () -> Bool, speech: SpeechTranscriptionCoordinator, @@ -67,18 +76,45 @@ final class MobileAssistantModel { let background = hostServices.background self.connections = connections self.background = background + let suspensionGate = MobileAssistantSuspensionGate() + self.suspensionGate = suspensionGate let titles = ConversationTitleController(ai: configuration) + final class CoordinatorBox { + weak var value: AssistantChatCoordinator? + } + let coordinatorBox = CoordinatorBox() execution = AssistantExecutionController( ai: configuration, skills: skills, repository: repository, memory: memory, modelSelections: modelSelections, authorize: authorize, - makeRegistry: { conversation, model, provider, catalog, channel, panes in + makeRegistry: { conversation, model, provider, catalog, channel, panes, source in AssistantToolRegistry.make( conversation: conversation, model: model, provider: provider, skills: catalog, channel: channel, ai: configuration, hosts: resolver, metrics: metrics, connections: connections, authentication: authentication, background: background, - panes: panes) + panes: panes, chatModels: coordinatorBox.value, + chats: coordinatorBox.value, chatSource: source) }, title: { try await titles.summarize($0) }, logger: logger) + chatCoordinator = AssistantChatCoordinator( + conversations: store, ai: configuration, modelSelections: modelSelections, + collaborationStore: chatCollaborationStore, + prepareDelivery: { [weak execution, suspensionGate] text, origin, conversation in + guard !suspensionGate.isSuspending else { return nil } + return execution?.prepareIncomingAgentMessage(text, origin: origin, in: conversation) + }, + persistDelivery: { [weak execution] prepared, conversation in + guard let execution else { throw CancellationError() } + try await execution.persistPreparedAgentDelivery(prepared, in: conversation) + }, + startDelivery: { [weak execution] prepared, conversation in + conversation.attachmentLoader = attachmentLoader + execution?.observeNotifications?(conversation) + execution?.startPreparedAgentDelivery(prepared, in: conversation) + }, + cancelDelivery: { [weak execution] prepared, conversation in + await execution?.cancelPreparedAgentDelivery(prepared, in: conversation) + }) + coordinatorBox.value = chatCoordinator execution.reportModelSelectionError = { [weak self] message in self?.error = message } } @@ -151,7 +187,7 @@ final class MobileAssistantModel { func expireBackground() { guard !isSuspending else { return } - isSuspending = true + suspensionGate.isSuspending = true execution.cancelPendingRuns() speech.cancelAll() let conversations = store.conversations @@ -161,7 +197,7 @@ final class MobileAssistantModel { for conversation in conversations { await conversation.stopAndWait() } await background.closeAll() await connections.closeAll() - isSuspending = false + suspensionGate.isSuspending = false suspensionTask = nil } } diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 276d1acd..30ed37fd 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -196,6 +196,24 @@ struct MobileAssistantImageFlowLayout: Layout { } } +@MainActor +struct MobileAssistantAgentOriginPresentation: Equatable { + let label: String + let canNavigate: Bool + + init( + origin: AgentMessageOrigin, source: ConversationModel?, + locale: Locale = .current + ) { + let sourceTitle = source?.derivedTitle ?? origin.sourceTitle + label = + sourceTitle.map { + AppLocalization.resolve(AppStrings.Assistant.agentFrom($0), locale: locale) + } ?? AppLocalization.resolve(AppStrings.Assistant.agent, locale: locale) + canNavigate = source != nil + } +} + struct MobileAssistantUserBubble: View { let text: String @@ -515,6 +533,12 @@ struct MobileAssistantConversation: View { .clipped() } } + .environment(\.assistantToolNavigation) { reference in + guard case .chat(let id) = reference, + let target = model.library.conversation(id: id) + else { return } + model.select(target) + } .background(Color(uiColor: .systemBackground)) .mobileConversationAttachmentDropTarget( conversation: conversation, @@ -881,15 +905,17 @@ struct MobileAssistantConversation: View { UIPasteboard.general.string = message.text } ShareLink(item: message.text) - if conversation.canEditUserMessage(message.id) { - Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { - editedText = message.text - editing = message + if message.origin == nil { + if conversation.canEditUserMessage(message.id) { + Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { + editedText = message.text + editing = message + } } - } - if conversation.canForkAssistantMessage(message.id) { - Button(AppStrings.Assistant.forkConversation.swiftUIKey, systemImage: "arrow.triangle.branch") { - Task { await model.fork(conversation, at: message.id) } + if conversation.canForkAssistantMessage(message.id) { + Button(AppStrings.Assistant.forkConversation.swiftUIKey, systemImage: "arrow.triangle.branch") { + Task { await model.fork(conversation, at: message.id) } + } } } } @@ -913,6 +939,24 @@ struct MobileAssistantConversation: View { UIPasteboard.general.string = message.text } + if let origin = message.origin { + let presentation = MobileAssistantAgentOriginPresentation( + origin: origin, + source: model.library.conversation(id: origin.sourceConversationID), + locale: locale) + Button(AppStrings.Assistant.agent.swiftUIKey) { + guard let target = model.library.conversation(id: origin.sourceConversationID) else { return } + model.select(target) + } + .font(.caption.weight(.medium)) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .disabled(!presentation.canNavigate) + .accessibilityLabel(Text(presentation.label)) + .help(presentation.label) + .accessibilityIdentifier("AssistantMessage.AgentOrigin") + } + if conversation.canForkAssistantMessage(message.id) { messageAction(AppStrings.Assistant.fork, systemImage: "arrow.triangle.branch") { Task { await model.fork(conversation, at: message.id) } diff --git a/TFITests/MobileAssistantAgentOriginTests.swift b/TFITests/MobileAssistantAgentOriginTests.swift new file mode 100644 index 00000000..1b5a91cf --- /dev/null +++ b/TFITests/MobileAssistantAgentOriginTests.swift @@ -0,0 +1,47 @@ +import AgentKit +import Foundation +import TermindApplication +import TermindPresentation +import Testing + +@testable import TermindIOS + +@Suite +@MainActor +struct MobileAssistantAgentOriginTests { + @Test + func markerUsesLiveTitleThenRetainsSnapshotAndDisablesNavigationAfterDeletion() { + let source = ConversationModel() + source.customTitle = "Live source" + let library = AssistantConversationStore(conversations: [source]) + let origin = AgentMessageOrigin( + sourceConversationID: source.id, sourceRunID: UUID(), + sourceToolCallID: "send", sourceTitle: "Snapshot source") + + for language in ["en", "zh-Hans", "zh-Hant"] { + let locale = Locale(identifier: language) + let live = MobileAssistantAgentOriginPresentation( + origin: origin, source: library.conversation(id: source.id), + locale: locale) + #expect(live.canNavigate) + #expect( + live.label + == AppLocalization.resolve( + AppStrings.Assistant.agentFrom("Live source"), locale: locale)) + } + + _ = library.remove(source.id) + for language in ["en", "zh-Hans", "zh-Hant"] { + let locale = Locale(identifier: language) + let deleted = MobileAssistantAgentOriginPresentation( + origin: origin, source: library.conversation(id: source.id), + locale: locale) + #expect(!deleted.canNavigate) + #expect( + deleted.label + == AppLocalization.resolve( + AppStrings.Assistant.agentFrom("Snapshot source"), + locale: locale)) + } + } +} diff --git a/TFM/App/AppDependencies.swift b/TFM/App/AppDependencies.swift index a41409cf..a8e253b8 100644 --- a/TFM/App/AppDependencies.swift +++ b/TFM/App/AppDependencies.swift @@ -23,6 +23,7 @@ struct AppDependencies { let serialPortService: any SerialPortServing let agentRunRepository: (any AgentRunPersisting)? let assistantModelSelectionStore: (any AssistantModelSelectionStoring)? + let assistantChatCollaborationStore: (any AssistantChatCollaborationStoring)? let settingsSync: SettingsSyncCoordinator? let metricsSync: MetricsSyncCoordinator? let vaultSync: VaultSyncCoordinator? @@ -41,6 +42,7 @@ struct AppDependencies { serialPortService: any SerialPortServing = SystemSerialPortService(), agentRunRepository: (any AgentRunPersisting)? = nil, assistantModelSelectionStore: (any AssistantModelSelectionStoring)? = nil, + assistantChatCollaborationStore: (any AssistantChatCollaborationStoring)? = nil, vaultSync: VaultSyncCoordinator? = nil, connectionHistorySync: ConnectionHistorySyncCoordinator? = nil, deviceSync: DeviceSyncCoordinator? = nil, @@ -59,6 +61,7 @@ struct AppDependencies { self.serialPortService = serialPortService self.agentRunRepository = agentRunRepository self.assistantModelSelectionStore = assistantModelSelectionStore + self.assistantChatCollaborationStore = assistantChatCollaborationStore self.vaultSync = vaultSync self.connectionHistorySync = connectionHistorySync self.deviceSync = deviceSync diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index c0aaf1c8..09d0b8a3 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -48,6 +48,9 @@ final class AppModel { weak var value: WindowModel? init(_ value: WindowModel) { self.value = value } } + private final class WeakAssistantChatCoordinator { + weak var value: AssistantChatCoordinator? + } private let hosts: HostLibraryModel let proAccess: ProAccessController @@ -134,6 +137,8 @@ final class AppModel { private let assistantConversations: AssistantConversationStore private let agentRunRepository: any AgentRunPersisting let assistantModelSelections: AssistantModelSelectionController + private let assistantChatCollaborationStore: any AssistantChatCollaborationStoring + @ObservationIgnored private let assistantChatCoordinatorBox = WeakAssistantChatCoordinator() private let agentHosts: AgentHostResolver private let agentConnections: AgentConnectionPool private let agentAuthentication: AgentSSHAuthenticationRegistry @@ -230,6 +235,8 @@ final class AppModel { assistantModelSelections = AssistantModelSelectionController( ai: ai, store: dependencies.assistantModelSelectionStore ?? InMemoryAssistantModelSelectionStore()) + assistantChatCollaborationStore = + dependencies.assistantChatCollaborationStore ?? InMemoryAssistantChatCollaborationStore() let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpSettings.preferences) let agentHostServices = AgentHostServices.live( store: hosts, @@ -399,6 +406,7 @@ final class AppModel { metricsSync?.start() settingsSync?.start() } + _ = assistantChatCoordinator } private func repairUnavailableAssistantModels() { @@ -475,13 +483,15 @@ final class AppModel { guard let self, !self.isTerminating else { return false } return self.proAccess.require(.assistant) }, - makeRegistry: { [weak self] conversation, model, provider, skills, channel, panes in + makeRegistry: { [weak self] conversation, model, provider, skills, channel, panes, source in guard let self else { return AgentToolRegistry([AnyAgentTool]()) } return AssistantToolRegistry.make( conversation: conversation, model: model, provider: provider, skills: skills, channel: channel, ai: ai, hosts: agentHosts, metrics: serverMetrics, connections: agentConnections, authentication: agentAuthentication, - background: agentBackgroundCommands, panes: panes) + background: agentBackgroundCommands, panes: panes, + chatModels: assistantChatCoordinatorBox.value, chats: assistantChatCoordinatorBox.value, + chatSource: source) }, title: { [weak self] context in guard let self else { throw CancellationError() } @@ -489,6 +499,32 @@ final class AppModel { }, logger: logging.makeLogger(label: "termind.agent")) + @ObservationIgnored private lazy var assistantChatCoordinator: AssistantChatCoordinator = { + let coordinator = AssistantChatCoordinator( + conversations: assistantConversations, ai: ai, + modelSelections: assistantModelSelections, + collaborationStore: assistantChatCollaborationStore, + prepareDelivery: { [weak self] text, origin, conversation in + self?.assistantExecution.prepareIncomingAgentMessage( + text, origin: origin, in: conversation) + }, + persistDelivery: { [weak self] prepared, conversation in + guard let self else { throw CancellationError() } + try await self.assistantExecution.persistPreparedAgentDelivery( + prepared, in: conversation) + }, + startDelivery: { [weak self] prepared, conversation in + self?.assistantNotifications.observe(conversation) + self?.assistantExecution.startPreparedAgentDelivery(prepared, in: conversation) + }, + cancelDelivery: { [weak self] prepared, conversation in + await self?.assistantExecution.cancelPreparedAgentDelivery( + prepared, in: conversation) + }) + assistantChatCoordinatorBox.value = coordinator + return coordinator + }() + func sendAgentDraft( _ conversation: ConversationModel, context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false, window: WindowModel? = nil diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 881cbcd1..67196f34 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -175,6 +175,9 @@ struct TermindApp: App { assistantModelSelectionStore: SwiftDataAssistantModelSelectionStore( context: container.mainContext ), + assistantChatCollaborationStore: SwiftDataAssistantChatCollaborationStore( + context: container.mainContext + ), vaultSync: vaultSync, connectionHistorySync: connectionHistorySync, deviceSync: deviceSync, settingsSync: settingsSync, metricsSync: metricsSync, metricsPreferences: metricsPersistence, diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index b970c0e5..0386bd90 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -307,6 +307,10 @@ private struct ConversationTranscriptSurface: View { isCompact: isCompact, showsTerminalActions: showsTerminalActions, isConversationStreaming: conversation.isStreaming, + chatDestinations: Dictionary( + uniqueKeysWithValues: window.assistantLibrary.conversations.map { + ($0.id, $0.derivedTitle) + }), locale: locale, reduceMotion: reduceMotion, colorScheme: colorScheme @@ -315,6 +319,7 @@ private struct ConversationTranscriptSurface: View { hostName: { id in app.hostLibrary.host(id)?.name ?? String(id.uuidString.prefix(8)) }, + navigateChat: { window.selectAssistant($0) }, editUserMessage: { id, text in window.editAssistantUserMessage( conversation.id, diff --git a/TFM/Features/AI/TranscriptListView.swift b/TFM/Features/AI/TranscriptListView.swift index 24bb8b0f..3f88798e 100644 --- a/TFM/Features/AI/TranscriptListView.swift +++ b/TFM/Features/AI/TranscriptListView.swift @@ -90,15 +90,31 @@ struct TranscriptListConfiguration: Equatable { let isCompact: Bool let showsTerminalActions: Bool let isConversationStreaming: Bool + let chatDestinations: [UUID: String] let locale: Locale let reduceMotion: Bool let colorScheme: ColorScheme var horizontalInset: CGFloat { isCompact ? Metrics.Space.l : Metrics.Space.xl } + + init( + isCompact: Bool, showsTerminalActions: Bool, isConversationStreaming: Bool, + chatDestinations: [UUID: String] = [:], locale: Locale, reduceMotion: Bool, + colorScheme: ColorScheme + ) { + self.isCompact = isCompact + self.showsTerminalActions = showsTerminalActions + self.isConversationStreaming = isConversationStreaming + self.chatDestinations = chatDestinations + self.locale = locale + self.reduceMotion = reduceMotion + self.colorScheme = colorScheme + } } @MainActor struct TranscriptListActions { var hostName: (UUID) -> String? = { _ in nil } + var navigateChat: (UUID) -> Void = { _ in } var editUserMessage: (UUID, String) -> Void = { _, _ in } var forkAssistantMessage: (UUID) -> Void = { _ in } var resolvePlan: (UUID, AgentPlan.Outcome) -> Void = { _, _ in } diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 9b4b0825..05368370 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -707,7 +707,8 @@ private enum NativeTranscriptFactory { let actions = NativeStack.horizontal() actions.identifier = NSUserInterfaceItemIdentifier("Transcript.MessageActions") actions.addArrangedSubview(NativeFlexibleSpacer()) - actions.addArrangedSubview( + let hoverActions = NativeStack.horizontal() + hoverActions.addArrangedSubview( NativeLabel( ChatMessageTimestampFormatter.string( for: message.createdAt, @@ -719,16 +720,43 @@ private enum NativeTranscriptFactory { ), color: .tertiaryLabelColor )) - if canEdit { + if canEdit, message.origin == nil { let edit = NativeButton(symbol: "pencil", help: String(localized: AppStrings.Common.edit)) edit.handler = { context.presentation.beginEditingMessage(message.id, text: message.text) context.invalidate(context.entryID, false) } - actions.addArrangedSubview(edit) + hoverActions.addArrangedSubview(edit) + } + hoverActions.addArrangedSubview(NativeCopyButton(text: message.text)) + actions.addArrangedSubview(hoverActions) + if message.origin == nil { + context.registerHoverView(actions) + } else { + context.registerHoverView(hoverActions) + } + if let origin = message.origin { + let agent = NativeButton(title: String(localized: AppStrings.Assistant.agent)) + agent.identifier = NSUserInterfaceItemIdentifier("Transcript.AgentOrigin") + agent.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) + let sourceTitle = + context.configuration.chatDestinations[origin.sourceConversationID] + ?? origin.sourceTitle + let label = + sourceTitle.map { + AppLocalization.resolve( + AppStrings.Assistant.agentFrom($0), + locale: context.configuration.locale) + } + ?? AppLocalization.resolve( + AppStrings.Assistant.agent, + locale: context.configuration.locale) + agent.toolTip = label + agent.setAccessibilityLabel(label) + agent.isEnabled = context.configuration.chatDestinations[origin.sourceConversationID] != nil + agent.handler = { context.actions.navigateChat(origin.sourceConversationID) } + actions.addArrangedSubview(agent) } - actions.addArrangedSubview(NativeCopyButton(text: message.text)) - context.registerHoverView(actions) outer.addArrangedSubview(actions) actions.widthAnchor.constraint(equalTo: outer.widthAnchor).isActive = true return outer @@ -835,7 +863,8 @@ private enum NativeTranscriptFactory { detail.addArrangedSubview(compactToolDetail(projected, card: card, context: context)) } else { for item in projected.items { - detail.addArrangedSubview(toolDetail(item, isError: card.result?.isError == true)) + detail.addArrangedSubview( + toolDetail(item, isError: card.result?.isError == true, context: context)) } } } @@ -1423,7 +1452,8 @@ private enum NativeTranscriptFactory { let expanded = context.presentation.disclosures.isExpanded(id) let body = NativeStack.vertical() for item in expanded ? preview.fullItems : preview.items { - body.addArrangedSubview(toolDetail(item, isError: card.result?.isError == true)) + body.addArrangedSubview( + toolDetail(item, isError: card.result?.isError == true, context: context)) } let scale = max(1, NSFont.preferredFont(forTextStyle: .caption1).pointSize / NSFont.smallSystemFontSize) let height = 180 * scale @@ -1456,7 +1486,9 @@ private enum NativeTranscriptFactory { return stack } - private static func toolDetail(_ item: AgentToolDetail.Item, isError: Bool) -> NSView { + private static func toolDetail( + _ item: AgentToolDetail.Item, isError: Bool, context: NativeTranscriptContext + ) -> NSView { switch item { case .field(let field): let row = NativeStack.horizontal(spacing: Metrics.Space.s) @@ -1471,7 +1503,14 @@ private enum NativeTranscriptFactory { field.isMonospaced ? .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) : .systemFont(ofSize: NSFont.smallSystemFontSize) - if let raw = field.url, let url = URL(string: raw) { + if case .some(.chat(let id)) = field.navigation { + let link = NativeButton(title: field.value) + link.font = font + link.contentTintColor = .linkColor + link.isEnabled = context.configuration.chatDestinations[id] != nil + link.handler = { context.actions.navigateChat(id) } + row.addArrangedSubview(link) + } else if let raw = field.url, let url = URL(string: raw) { let link = NativeButton(title: field.value) link.font = font link.contentTintColor = .linkColor @@ -1546,7 +1585,7 @@ private enum NativeTranscriptFactory { color: .secondaryLabelColor )) } else { - for row in list.rows { stack.addArrangedSubview(toolListRow(row)) } + for row in list.rows { stack.addArrangedSubview(toolListRow(row, context: context)) } } return stack case .groupedList(let list): @@ -1563,13 +1602,15 @@ private enum NativeTranscriptFactory { section.title, font: .boldSystemFont(ofSize: NSFont.smallSystemFontSize) )) - for row in section.rows { stack.addArrangedSubview(toolListRow(row)) } + for row in section.rows { stack.addArrangedSubview(toolListRow(row, context: context)) } } return stack } } - private static func toolListRow(_ row: AgentToolDetail.ListRow) -> NSView { + private static func toolListRow( + _ row: AgentToolDetail.ListRow, context: NativeTranscriptContext + ) -> NSView { let outer = NativeStack.horizontal(spacing: Metrics.Space.s) if let symbol = row.symbol { let icon = NSImageView( @@ -1580,7 +1621,14 @@ private enum NativeTranscriptFactory { outer.addArrangedSubview(icon) } let body = NativeStack.vertical(spacing: Metrics.Space.xxs) - if let raw = row.url, let url = URL(string: raw) { + if case .some(.chat(let id)) = row.navigation { + let link = NativeButton(title: row.title) + link.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) + link.contentTintColor = .linkColor + link.isEnabled = context.configuration.chatDestinations[id] != nil + link.handler = { context.actions.navigateChat(id) } + body.addArrangedSubview(link) + } else if let raw = row.url, let url = URL(string: raw) { let link = NativeButton(title: row.title) link.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) link.contentTintColor = .linkColor diff --git a/TFMTests/AgentOriginRenderingTests.swift b/TFMTests/AgentOriginRenderingTests.swift new file mode 100644 index 00000000..17b737b6 --- /dev/null +++ b/TFMTests/AgentOriginRenderingTests.swift @@ -0,0 +1,100 @@ +import AgentKit +import AppKit +import Foundation +import SwiftUI +import TermindApplication +import TermindPresentation +import Testing + +@testable import Termind + +@Suite +@MainActor +struct AgentOriginRenderingTests { + @Test + func markerRetainsSnapshotAndCopyButDisablesNavigationAfterSourceDeletion() throws { + let sourceID = UUID() + let messageID = UUID() + let message = ChatMessage( + id: messageID, role: .user, text: "Delegated text", + origin: AgentMessageOrigin( + sourceConversationID: sourceID, sourceRunID: UUID(), + sourceToolCallID: "send", sourceTitle: "Snapshot source")) + let entry = TranscriptEntry( + id: messageID, content: .user(message, canEdit: true)) + let row = TranscriptNativeRow.make(for: .user) + var navigated: UUID? + let actions = TranscriptListActions( + navigateChat: { navigated = $0 }) + + configure( + row, entry: entry, chatDestinations: [sourceID: "Live source"], + actions: actions) + var buttons = descendantButtons(in: row) + let liveMarker = try #require( + buttons.first { + $0.identifier?.rawValue == "Transcript.AgentOrigin" + }) + #expect(liveMarker.isEnabled) + #expect( + liveMarker.toolTip + == AppLocalization.resolve( + AppStrings.Assistant.agentFrom("Live source"), + locale: Locale(identifier: "en"))) + expectVisibleAncestors(from: liveMarker, through: row) + #expect(buttons.contains { $0.toolTip == String(localized: AppStrings.Common.copy) }) + #expect(!buttons.contains { $0.toolTip == String(localized: AppStrings.Common.edit) }) + liveMarker.performClick(nil) + #expect(navigated == sourceID) + + configure(row, entry: entry, chatDestinations: [:], actions: actions) + buttons = descendantButtons(in: row) + let deletedMarker = try #require( + buttons.first { + $0.identifier?.rawValue == "Transcript.AgentOrigin" + }) + #expect(!deletedMarker.isEnabled) + #expect( + deletedMarker.toolTip + == AppLocalization.resolve( + AppStrings.Assistant.agentFrom("Snapshot source"), + locale: Locale(identifier: "en"))) + expectVisibleAncestors(from: deletedMarker, through: row) + #expect(buttons.contains { $0.toolTip == String(localized: AppStrings.Common.copy) }) + #expect(!buttons.contains { $0.toolTip == String(localized: AppStrings.Common.edit) }) + } + + private func configure( + _ row: TranscriptNativeRow, entry: TranscriptEntry, + chatDestinations: [UUID: String], actions: TranscriptListActions + ) { + row.configure( + entry: entry, proposedWidth: 420, + configuration: .init( + isCompact: false, showsTerminalActions: false, + isConversationStreaming: false, + chatDestinations: chatDestinations, + locale: Locale(identifier: "en"), reduceMotion: true, + colorScheme: .light), + actions: actions, presentation: TranscriptPresentationState(), + markdownCache: TranscriptMarkdownCache(limit: 4), + measurement: false, invalidate: { _, _ in }) + row.layoutSubtreeIfNeeded() + } + + private func descendantButtons(in view: NSView) -> [NSButton] { + view.subviews.flatMap { child in + (child as? NSButton).map { [$0] } ?? descendantButtons(in: child) + } + } + + private func expectVisibleAncestors(from view: NSView, through root: NSView) { + var current: NSView? = view + while let node = current { + #expect(node.alphaValue > 0) + if node === root { return } + current = node.superview + } + Issue.record("Marker is not attached to its transcript row") + } +} diff --git a/TFMTests/AgentPersistenceTests.swift b/TFMTests/AgentPersistenceTests.swift index 3c169e87..d2828e8d 100644 --- a/TFMTests/AgentPersistenceTests.swift +++ b/TFMTests/AgentPersistenceTests.swift @@ -868,6 +868,55 @@ struct AgentPersistenceTests { #expect(restored.messages.map(\.text) == ["One", "Answer one", "Edited"]) } + @Test(arguments: [false, true]) + func truncationRejectsSuffixToolsWithoutMutatingHistory(_ hasTranscriptCall: Bool) async throws { + let container = try TermindPersistence.makeContainer(inMemory: true) + let repository = SwiftDataAgentRunRepository(context: container.mainContext) + let conversationID = UUID() + let runID = UUID() + let instant = Date(timeIntervalSinceReferenceDate: 100) + let opening = AgentTranscriptMessage(role: .user, text: "Opening", createdAt: instant) + let steering = AgentTranscriptMessage(role: .user, text: "Steering", createdAt: instant) + let call = AgentToolCall(id: "suffix", name: "inspect", arguments: .object([:])) + let answer = AgentTranscriptMessage( + role: .assistant, text: "Answer", toolCalls: hasTranscriptCall ? [call] : [], createdAt: instant) + let messages = [opening, steering, answer] + try await repository.save( + AgentRunSnapshot( + id: runID, conversationID: conversationID, state: .failed, + permissionMode: .askForApproval, messages: messages, + startedAt: instant, finishedAt: instant)) + let invocation = AgentToolInvocation( + runID: runID, call: call, + sourceMessageID: hasTranscriptCall ? answer.id : UUID(), createdAt: instant) + try await repository.record( + .toolProposed( + invocation, + AgentToolDescriptor( + name: "inspect", summary: "Inspect", inputSchema: .object([:]), + target: .local, approvalPolicy: .approve)), runID: runID) + let before = try #require(try await repository.loadConversations().first) + let replacement = AgentTranscriptMessage(role: .user, text: "Replacement") + let store = AssistantConversationStore() + store.restore([before]) + let live = try #require(store.conversations.first) + #expect(!live.canEditUserMessage(steering.id)) + #expect( + try await AssistantConversationOperations(repository: repository).edit( + live, messageID: steering.id, replacement: replacement) == nil) + + await #expect(throws: AgentConversationPersistenceError.self) { + try await repository.truncateConversation( + conversationID, fromUserMessageID: steering.id, replacingWith: replacement) + } + + let after = try #require(try await repository.loadConversations().first) + #expect(after.messages.map(\.id) == before.messages.map(\.id)) + #expect(after.toolCards.map(\.invocation.id) == before.toolCards.map(\.invocation.id)) + #expect(after.toolCards.map(\.state) == before.toolCards.map(\.state)) + #expect(after.runs == before.runs) + } + @Test func sameRunTruncationPreservesThePrefixAndAtomicallyAdoptsTheReplacement() async throws { let configuration = localModelConfiguration( @@ -907,15 +956,10 @@ struct AgentPersistenceTests { role: .user, text: "Steer", authoredText: "Steer", contextAttachments: [removedAttachment], contextSnapshot: .init(sessionContext: "removed context"), createdAt: instant) - let suffixCall = AgentToolCall( - id: "suffix-call", name: "inspect", arguments: .object([:])) let suffixAssistant = AgentTranscriptMessage( - role: .assistant, text: "Suffix", toolCalls: [suffixCall], createdAt: instant) - let suffixResult = AgentTranscriptMessage( - role: .tool, text: "suffix result", toolCallID: suffixCall.id, - toolName: suffixCall.name, modelText: "suffix preview", createdAt: instant) + role: .assistant, text: "Suffix", createdAt: instant) let oldMessages = [ - opening, prefixAssistant, prefixResult, steering, suffixAssistant, suffixResult, + opening, prefixAssistant, prefixResult, steering, suffixAssistant, ] let replacement = AgentTranscriptMessage( id: replacementID, role: .user, text: "Replacement", authoredText: "Replacement", @@ -953,36 +997,17 @@ struct AgentPersistenceTests { let prefixInvocation = AgentToolInvocation( runID: oldRunID, call: prefixCall, sourceMessageID: nil, createdAt: instant.addingTimeInterval(1)) - let suffixInvocation = AgentToolInvocation( - runID: oldRunID, call: suffixCall, - sourceMessageID: suffixAssistant.id, createdAt: instant) let review = GuardianDecision( verdict: .approve, risk: .low, userAuthorization: .high, reason: "Allowed") - for invocation in [prefixInvocation, suffixInvocation] { - try await repository.record(.toolProposed(invocation, descriptor), runID: oldRunID) - try await repository.record( - .toolFinished( - invocation, - AgentToolResult(callID: invocation.call.id, content: "recorded result")), - runID: oldRunID) - let request = AgentApprovalRequest( - invocation: invocation, descriptor: descriptor, userIntent: "Inspect") - try await repository.record(.reviewFinished(request, review), runID: oldRunID) - } - // Provider-native tools can finish durably before their synthetic assistant - // source message reaches a snapshot. Its unknown source must fall through - // to the conservative timestamp rule when this steering suffix is edited. - let orphanInvocation = AgentToolInvocation( - runID: oldRunID, - call: AgentToolCall(id: "native-call", name: "web_search", arguments: .object([:])), - sourceMessageID: UUID(), createdAt: instant) - try await repository.record(.toolProposed(orphanInvocation, descriptor), runID: oldRunID) + try await repository.record(.toolProposed(prefixInvocation, descriptor), runID: oldRunID) try await repository.record( .toolFinished( - orphanInvocation, - AgentToolResult(callID: orphanInvocation.call.id, content: "native result")), + prefixInvocation, + AgentToolResult(callID: prefixInvocation.call.id, content: "recorded result")), runID: oldRunID) - + let request = AgentApprovalRequest( + invocation: prefixInvocation, descriptor: descriptor, userIntent: "Inspect") + try await repository.record(.reviewFinished(request, review), runID: oldRunID) let nilBoundary = AgentCompactionRecord( compactedThroughMessageID: nil, sequence: 0, summary: "Legacy base", summarizedTokens: 4, keptTokens: 1, displayAnchor: instant) @@ -996,10 +1021,7 @@ struct AgentPersistenceTests { let preEditStore = AssistantConversationStore() preEditStore.restore(try await repository.loadConversations()) let persistedLive = try #require(preEditStore.conversations.first) - #expect( - Set(persistedLive.toolCards.map(\.invocation.id)) == [ - prefixInvocation.id, suffixInvocation.id, orphanInvocation.id, - ]) + #expect(persistedLive.toolCards.map(\.invocation.id) == [prefixInvocation.id]) let operations = AssistantConversationOperations(repository: repository) #expect( try await operations.edit( diff --git a/TFMTests/AgentTokenUsageTests.swift b/TFMTests/AgentTokenUsageTests.swift index 590d919a..5e16ca19 100644 --- a/TFMTests/AgentTokenUsageTests.swift +++ b/TFMTests/AgentTokenUsageTests.swift @@ -338,7 +338,12 @@ struct AgentTokenUsageTests { )) expectSnapshotMatchesReference(conversation) - _ = try #require(conversation.editUserMessage(opening.id, authoredText: "edited")) + #expect(conversation.editUserMessage(opening.id, authoredText: "edited") == nil) + expectSnapshotMatchesReference(conversation) + + let followUp = conversation.appendUserTurnContent("Explain the result") + conversation.handle(.messageFinished(AgentTranscriptMessage(role: .assistant, text: "explanation"))) + _ = try #require(conversation.editUserMessage(followUp.id, authoredText: "edited")) expectSnapshotMatchesReference(conversation) conversation.applyCompaction(summary: "summary", cut: 1) diff --git a/TFMTests/AgentToolPresentationTests.swift b/TFMTests/AgentToolPresentationTests.swift index 3534074a..bc181c94 100644 --- a/TFMTests/AgentToolPresentationTests.swift +++ b/TFMTests/AgentToolPresentationTests.swift @@ -262,6 +262,140 @@ struct AgentToolPresentationTests { #expect(block.text.contains("\"internal_flag\"")) } + @Test + func everyChatCollaborationToolUsesCompactDetails() { + for name in [ + "list_models", "create_new_chat", "list_chats", "read_chat", + "send_to_chat", "wait_chats", + ] { + let card = makeCard(name: name, target: .local) + #expect( + AgentToolPresentation.usesCompactDetails(for: card.descriptor), + Comment(rawValue: "\(name) should use bounded compact details")) + } + } + + @Test + func missingChatApprovalPresenterNeverFallsBackToPromptOrMessageFields() { + for (name, arguments, secret) in [ + ( + "create_new_chat", + ["prompt": AgentJSONValue.string("private create body")], + "private create body" + ), + ( + "send_to_chat", + [ + "chat_id": AgentJSONValue.string(UUID().uuidString), + "message": AgentJSONValue.string("private send body"), + ], + "private send body" + ), + ] { + let card = makeCard(name: name, target: .local, arguments: arguments) + let request = AgentApprovalRequest( + invocation: card.invocation, descriptor: card.descriptor, + userIntent: "Collaborate") + + let approval = AgentToolPresentation.approval( + for: request, presenter: nil, locale: Locale(identifier: "en")) + let rendered = AgentToolDetailPreview(approval.detail).fullText + #expect(!rendered.contains(secret)) + #expect(approval.detail.items.isEmpty) + } + } + + @Test + func chatFallbacksNeverExposeMalformedErrorOrUnprojectedBodies() throws { + let privateBody = "private routed body" + let validPayload = AgentJSONValue.object([ + "chat_id": .string(UUID().uuidString), + "title": .string("Target"), + "status": .string("delivered"), + "message": .string(privateBody), + ]).encodedString + let cases: [(String, AgentToolResult, AgentToolDetailPresenter?)] = [ + ( + "malformed", + AgentToolResult(callID: "call", content: privateBody), + TermindAgentTools.presenter(id: "builtin.send_to_chat") + ), + ( + "error", + AgentToolResult(callID: "call", content: privateBody, isError: true), + TermindAgentTools.presenter(id: "builtin.send_to_chat") + ), + ( + "missing presenter", + AgentToolResult(callID: "call", content: validPayload), + nil + ), + ] + + for (label, result, presenter) in cases { + var card = makeCard( + name: "send_to_chat", target: .local, + presentation: .init( + symbol: "paperplane", activity: .semanticLabel(.chat), + output: .json, presenterID: "builtin.send_to_chat", + actionKind: .send)) + card.result = result + if result.isError { card.state = .failed } + let detail = try #require( + AgentToolPresentation.detail( + for: card, presenter: presenter, + locale: Locale(identifier: "en")), + Comment(rawValue: label)) + let rendered = AgentToolDetailPreview(detail).fullText + #expect(!rendered.contains(privateBody), Comment(rawValue: label)) + #expect(!rendered.contains("\"message\""), Comment(rawValue: label)) + #expect(rendered == "Chat details unavailable", Comment(rawValue: label)) + } + } + + @Test + func chatCompactPreviewAndFullCopyContainOnlyProjectedBoundedFields() throws { + let secret = "body-that-must-never-render" + let chats = (0..<12).map { index in + AgentJSONValue.object([ + "chat_id": .string(UUID().uuidString), + "title": .string("Chat \(index)"), + "status": .string("completed"), + "message": .string("\(secret)-\(index)"), + ]) + } + var card = makeCard( + name: "list_chats", target: .local, + presentation: .init( + symbol: "bubble.left.and.bubble.right", + activity: .semanticLabel(.chats), output: .json, + presenterID: "builtin.list_chats", actionKind: .list)) + card.result = AgentToolResult( + callID: "call", + content: AgentJSONValue.object(["chats": .array(chats)]).encodedString) + + let detail = try #require( + AgentToolPresentation.detail( + for: card, + presenter: TermindAgentTools.presenter(id: "builtin.list_chats"), + locale: Locale(identifier: "en"))) + let preview = AgentToolDetailPreview(detail) + + #expect(preview.isShortened) + #expect(preview.fullText.contains("Chat 11")) + #expect(!preview.fullText.contains(secret)) + let displayed = AgentToolDetailPreview( + AgentToolDetail(items: preview.items) + ).fullText + #expect(!displayed.contains(secret)) + guard case .list(let list) = try #require(preview.items.first) else { + Issue.record("Expected compact chat list") + return + } + #expect(list.rows.count == AgentToolDetailPreview.rowLimit) + #expect(displayed.count <= AgentToolDetailPreview.characterLimit + 20) + } + @Test func unknownPresenterIDCannotSelectOrImpersonateBuiltInCode() throws { var card = makeCard( diff --git a/TFMTests/ChatComposerStateTests.swift b/TFMTests/ChatComposerStateTests.swift index bc6b46d9..e67e06c2 100644 --- a/TFMTests/ChatComposerStateTests.swift +++ b/TFMTests/ChatComposerStateTests.swift @@ -2257,7 +2257,7 @@ struct ChatComposerStateTests { } @Test - func editingAUserTurnRemovesItsDownstreamWorkGroup() throws { + func editingAUserTurnWithDownstreamToolActivityIsRejectedWithoutChangingHistory() { let conversation = ConversationModel() let user = conversation.appendUserTurnContent("Question") let runID = UUID() @@ -2283,15 +2283,46 @@ struct ChatComposerStateTests { ))) #expect(conversation.workGroups.count == 1) - _ = try #require(conversation.editUserMessage(user.id, authoredText: "Edited")) + #expect(!conversation.canEditUserMessage(user.id)) + #expect(conversation.editUserMessage(user.id, authoredText: "Edited") == nil) - #expect(conversation.workGroups.isEmpty) - #expect(conversation.toolCards.isEmpty) + #expect(conversation.workGroups.count == 1) + #expect(conversation.toolCards.map(\.invocation.id) == [invocation.id]) #expect(conversation.runSummaries.count == 1) - let staging = try #require(conversation.runSummaries.values.first) - #expect(staging.id == conversation.transcript.last?.id) - #expect(staging.state == .completed) - #expect(staging.failure == nil) + #expect(conversation.runSummaries[runID] != nil) + #expect(conversation.transcript.map(\.id) == [user.id, process.id]) + } + + @Test(arguments: [ + AgentToolCard.State.proposed, .waitingForApproval, .waitingForUser, .reviewing, + .running, .completed, .denied, .failed, .interrupted, + ]) + func editEligibilityUsesToolOwnershipForEveryLifecycleState(_ state: AgentToolCard.State) { + let instant = Date(timeIntervalSinceReferenceDate: 100) + let runID = UUID() + let opening = AgentTranscriptMessage(role: .user, text: "Opening", createdAt: instant) + let process = AgentTranscriptMessage(role: .assistant, text: "Checking", createdAt: instant) + let later = AgentTranscriptMessage(role: .user, text: "Later", createdAt: instant) + let answer = AgentTranscriptMessage(role: .assistant, text: "Answer", createdAt: instant) + let transcript = [opening, process, later, answer] + let conversation = ConversationModel( + messages: transcript.map { + ChatMessage(id: $0.id, role: $0.role == .user ? .user : .assistant, text: $0.text) + }, transcript: transcript, runState: .completed, + messageRunIDs: Dictionary(uniqueKeysWithValues: transcript.map { ($0.id, runID) }), + runs: [AgentRunSummary(id: runID, state: .completed, startedAt: instant)]) + conversation.toolCards = [ + AgentToolCard( + invocation: AgentToolInvocation( + runID: runID, call: AgentToolCall(id: "read", name: "lookup", arguments: .object([:])), + sourceMessageID: process.id, createdAt: instant.addingTimeInterval(-60)), + descriptor: AgentToolDescriptor( + name: "lookup", summary: "Read-only lookup", inputSchema: .object([:]), + target: .local, approvalPolicy: .approve), state: state) + ] + + #expect(!conversation.canEditUserMessage(opening.id)) + #expect(conversation.canEditUserMessage(later.id)) } @Test diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 247b6d2b..f5f53842 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2866,7 +2866,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.15.2; + minimumVersion = 0.16.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 916ed511..ed4c0d39 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "4540847d4762f9040be7a8bab4cadd82e494c36d", - "version" : "0.15.2" + "revision" : "f78416b2b8523d09950872b96f0de841e2d704e8", + "version" : "0.16.0" } }, {