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.
This commit is contained in:
1 parent
abee0e98f5
commit
c1ae0dc0a2
40 files changed
+2483
-102
No files matched your search
+1
-1
Submodule AgentKit updated: 4540847d47...f78416b2b8.
@@ -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<PersistentAgentMessageReplay>()) == 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<AgentModelStreamEvent, any Error> {
|
||||
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 {}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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<PersistentCloudSyncRecovery>()) == 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<PersistentHostGroup>()).map(\.name)
|
||||
== ["Before collaboration"])
|
||||
#expect(
|
||||
try migrated.mainContext.fetchCount(
|
||||
FetchDescriptor<PersistentAgentMessageOrigin>()) == 0)
|
||||
#expect(
|
||||
try migrated.mainContext.fetchCount(
|
||||
FetchDescriptor<PersistentAgentConversationOrigin>()) == 0)
|
||||
#expect(
|
||||
try migrated.mainContext.fetchCount(
|
||||
FetchDescriptor<PersistentAgentDeliveryReceipt>()) == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func version25StoreMigratesWithNoStartupSnippet() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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<UUID>, continuation: AsyncStream<Void>.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<UUID>) -> AsyncStream<Void> {
|
||||
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[..<boundary.lowerBound]))
|
||||
else { return nil }
|
||||
return AIModelRef(
|
||||
modelProviderID: providerID, modelID: String(value[boundary.upperBound...]))
|
||||
}
|
||||
|
||||
private static func offset(_ cursor: String?) throws -> 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 }
|
||||
}
|
||||
@@ -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<Void, Never> {
|
||||
var cancellationTasks: [Task<Void, Never>] = []
|
||||
@@ -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? = {
|
||||
|
||||
@@ -88,6 +88,7 @@ public final class AssistantConversationStore {
|
||||
contextAttachments: message.contextAttachments
|
||||
),
|
||||
images: message.images,
|
||||
origin: message.origin,
|
||||
createdAt: message.createdAt
|
||||
)
|
||||
case .assistant:
|
||||
|
||||
@@ -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<String>()
|
||||
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[..<target].map(\.id)).union(visiblePrefix.map(\.id))
|
||||
let removedCallIDs = Set(removed.flatMap { $0.toolCalls.map(\.id) })
|
||||
let keptCallIDs = Set(transcript[..<target].flatMap { $0.toolCalls.map(\.id) })
|
||||
let keptRunIDs = Set(keptMessageIDs.compactMap { messageRunIDs[$0] })
|
||||
var removedRunIDs = Set(removedMessageIDs.compactMap { messageRunIDs[$0] })
|
||||
let orderedRuns = runSummaries.values.sorted {
|
||||
if $0.startedAt != $1.startedAt { return $0.startedAt < $1.startedAt }
|
||||
return $0.id.uuidString < $1.id.uuidString
|
||||
}
|
||||
let targetRunID = messageRunIDs[original.id]
|
||||
if let targetRunID, let runIndex = orderedRuns.firstIndex(where: { $0.id == targetRunID }) {
|
||||
removedRunIDs.formUnion(orderedRuns.dropFirst(runIndex + 1).map(\.id))
|
||||
}
|
||||
return toolCards.contains { card in
|
||||
if let targetRunID {
|
||||
if card.invocation.runID != targetRunID {
|
||||
if keptRunIDs.contains(card.invocation.runID) { return false }
|
||||
if removedRunIDs.contains(card.invocation.runID) { return true }
|
||||
}
|
||||
} else if removedRunIDs.contains(card.invocation.runID) {
|
||||
return true
|
||||
}
|
||||
if let source = card.invocation.sourceMessageID {
|
||||
if removedMessageIDs.contains(source) { return true }
|
||||
if keptMessageIDs.contains(source) { return false }
|
||||
}
|
||||
if removedCallIDs.contains(card.invocation.call.id) { return true }
|
||||
if keptCallIDs.contains(card.invocation.call.id) { return false }
|
||||
let resultMessageID = AgentTranscriptMessage.toolResultID(
|
||||
runID: card.invocation.runID, callID: card.invocation.call.id)
|
||||
if removedMessageIDs.contains(resultMessageID) { return true }
|
||||
if keptMessageIDs.contains(resultMessageID) { return false }
|
||||
// Historical cards may not have source ownership. Timestamp is the
|
||||
// conservative fallback already used by persisted truncation.
|
||||
return card.invocation.createdAt >= 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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<PersistentAgentDeliveryReceipt>(
|
||||
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<PersistentAgentDeliveryReceipt>(
|
||||
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<PersistentAgentDeliveryReceipt>(
|
||||
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<PersistentAgentDeliveryReceipt>(
|
||||
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<PersistentAgentConversationOrigin>(
|
||||
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<PersistentAgentConversationOrigin>(
|
||||
predicate: #Predicate { $0.conversationID == conversationID }))
|
||||
for row in rows { context.delete(row) }
|
||||
if !rows.isEmpty { try context.savePendingChanges() }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PersistentAgentMessageReplay>()).map { ($0.messageID, $0) })
|
||||
let originByMessage = Dictionary(
|
||||
uniqueKeysWithValues:
|
||||
try context.fetch(FetchDescriptor<PersistentAgentMessageOrigin>()).map { ($0.messageID, $0.value) })
|
||||
let compactionRows = try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
|
||||
let attachmentRows = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentContextAttachments>()
|
||||
@@ -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<PersistentAgentMessageOrigin>())
|
||||
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<PersistentAgentToolInvocation>())
|
||||
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<PersistentAgentConversation>(
|
||||
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<PersistentAgentMessageReplay>())
|
||||
where messageIDs.contains(row.messageID) { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentMessageOrigin>())
|
||||
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<PersistentAgentMessageReplay>())
|
||||
where messageIDs.contains(row.messageID) { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentMessageOrigin>())
|
||||
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<PersistentAgentDeliveryReceipt>(
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -12,6 +12,10 @@ nonisolated public enum AgentToolPresentation {
|
||||
public static let displayCharacterLimit = 8_192
|
||||
public static let displayLineLimit = 120
|
||||
|
||||
private static let chatCollaborationToolNames: Set<String> = [
|
||||
"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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var suspensionTask: Task<Void, Never>?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+38
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user