Files
Termind/Frameworks/TermindApplication/Agent/AssistantConversationOperations.swift
T
Kaede Akino 7e78bc00c4 feat(agent): unify output truncation and scratch capture
Integrate AgentKit 2.0.1 with shared scratch storage, whole-line tool policies, generated capture paths, and external MCP projection bypass. Update ssh-exec instructions and document the complete tool policy inventory.

Keep compact expansion controls left aligned and display the AgentKit truncation marker above them on both platforms. Cover narrow and wide native layouts, both button states, and marker ordering.
2026-10-17 22:51:26 +08:00

149 lines
6.0 KiB
Swift

import AgentKit
import Foundation
/// Durable conversation mutations commit before changing observable state.
@MainActor
public final class AssistantConversationOperations {
private let repository: any AgentRunPersisting
private let modelSelections: AssistantModelSelectionController?
private var pending = Set<UUID>()
public init(
repository: any AgentRunPersisting,
modelSelections: AssistantModelSelectionController? = nil
) {
self.repository = repository
self.modelSelections = modelSelections
}
public func setArchived(_ value: Bool, conversation: ConversationModel) async throws {
await conversation.cancelPendingAgentPreparationAndWait()
guard !conversation.isStreaming, !conversation.isCompacting,
pending.insert(conversation.id).inserted
else { return }
conversation.isHistoryChanging = true
defer {
pending.remove(conversation.id)
conversation.isHistoryChanging = false
}
var metadata = conversation.metadata
metadata.archivedAt = value ? .now : nil
if value { metadata.isPinned = false }
try await repository.setConversationMetadata(metadata, conversationID: conversation.id)
conversation.archivedAt = metadata.archivedAt
conversation.isPinned = metadata.isPinned
}
public func rename(_ conversation: ConversationModel, title: String) async throws {
let title = String(title.trimmingCharacters(in: .whitespacesAndNewlines).prefix(80))
guard !title.isEmpty, pending.insert(conversation.id).inserted else { return }
conversation.isHistoryChanging = true
defer {
pending.remove(conversation.id)
conversation.isHistoryChanging = false
}
var metadata = conversation.metadata
metadata.customTitle = title
try await repository.setConversationMetadata(metadata, conversationID: conversation.id)
conversation.customTitle = title
}
public func move(
_ conversation: ConversationModel, pinned: Bool, before nextID: UUID?, library: AssistantLibraryModel
) async throws {
guard !conversation.isArchived,
pending.insert(conversation.id).inserted
else { return }
conversation.isHistoryChanging = true
defer {
pending.remove(conversation.id)
conversation.isHistoryChanging = false
}
guard let updates = library.orderUpdates(moving: conversation.id, toPinned: pinned, before: nextID) else {
return
}
try await repository.reorderConversations(updates)
library.applyOrder(updates)
}
@discardableResult
public func delete(_ conversation: ConversationModel) async throws -> (deleted: Bool, cleanupError: String?) {
await conversation.cancelPendingAgentPreparationAndWait()
guard !conversation.isStreaming, !conversation.isCompacting,
pending.insert(conversation.id).inserted
else { return (false, nil) }
conversation.isHistoryChanging = true
defer {
pending.remove(conversation.id)
conversation.isHistoryChanging = false
}
try await repository.deleteConversation(conversation.id)
await conversation.secretBroker.discardAll()
return (true, nil)
}
public func fork(_ source: ConversationModel, at messageID: UUID, title: String) async throws -> ConversationModel?
{
guard let fork = source.forked(throughAssistantMessage: messageID, title: title),
pending.insert(source.id).inserted
else { return nil }
source.isHistoryChanging = true
defer {
pending.remove(source.id)
source.isHistoryChanging = false
}
let stored = StoredAgentConversation(
id: fork.id, messages: fork.transcript, modelTranscript: fork.transcript,
compactions: fork.compactionRecords, messageRunIDs: fork.messageRunIDs,
runs: Array(fork.runSummaries.values),
toolCards: fork.toolCards.map {
StoredAgentToolCard(
invocation: $0.invocation, descriptor: $0.descriptor,
state: $0.state.rawValue, result: $0.result, review: $0.review)
}, lastRunState: .completed, title: "", metadata: fork.metadata)
do {
try await repository.createConversation(stored)
do {
try modelSelections?.persistFork(fork)
} catch {
try? await repository.deleteConversation(fork.id)
throw error
}
return fork
} catch {
throw error
}
}
public func edit(
_ conversation: ConversationModel, messageID: UUID, text: String,
contextSnapshot: AgentTurnContextSnapshot? = nil
) async throws -> ConversationModel
.ComposedUserTurn?
{
guard var replacement = conversation.editedUserMessage(messageID, authoredText: text) else { return nil }
replacement.contextSnapshot = contextSnapshot
return try await edit(conversation, messageID: messageID, replacement: replacement)
}
public func edit(
_ conversation: ConversationModel, messageID: UUID,
replacement: AgentTranscriptMessage
) async throws -> ConversationModel.ComposedUserTurn? {
try Task.checkCancellation()
guard conversation.canApplyUserMessageEdit(messageID, replacement: replacement),
pending.insert(conversation.id).inserted
else { return nil }
conversation.isHistoryChanging = true
defer {
pending.remove(conversation.id)
conversation.isHistoryChanging = false
}
try Task.checkCancellation()
try await repository.truncateConversation(
conversation.id, fromUserMessageID: messageID, replacingWith: replacement)
conversation.isHistoryChanging = false
return conversation.applyPersistedUserMessageEdit(messageID, replacement: replacement)
}
}