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:
Kaede Akino committed 2026-09-27 19:08:29 +08:00
1 parent abee0e98f5
commit c1ae0dc0a2
40 files changed
+2483 -102

No files matched your search

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)
),
+2
View File
@@ -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))
}
}
}
+3
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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,
+16
View File
@@ -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 }
+61 -13
View File
@@ -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
+100
View File
@@ -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")
}
}
+58 -36
View File
@@ -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(
+6 -1
View File
@@ -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)
+134
View File
@@ -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(
+39 -8
View File
@@ -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
+1 -1
View File
@@ -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"
}
},
{