Files
Termind/Frameworks/TermindApplication/Agent/AssistantConversationOperations.swift
T
Kaede Akino ebeb0027d4 feat(ssh)!: redesign command execution tools
Split command execution into run, output, stdin, cancel and list tools for the assistant and MCP. Preserve bounded output tails and use conversation-scoped handles and secret storage with AgentKit 1.20.0.

Add the built-in ssh-exec skill with timestamped log redirection and text/secret stdin workflows. Expand long tool output without adjacent copy controls, update localized presentation, and refresh regression coverage and the website tool name.

BREAKING CHANGE: Replace ssh_exec and ssh_wait with ssh_exec_run and ssh_exec_output. Move stdin to ssh_exec_stdin and remove execution timeout and cwd arguments. Use run_in_background to control foreground execution.
2026-10-17 12:26:48 +08:00

163 lines
6.7 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()
do {
try await AgentScratchWorkspace(
conversationID: conversation.id,
applicationName: TermindAgentPolicy.scratchDirectoryName
).removeScope()
return (true, nil)
} catch { return (true, error.localizedDescription) }
}
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 Task.detached(priority: .utility) {
try AgentScratchWorkspace.copyScope(
from: source.id, to: fork.id,
applicationName: TermindAgentPolicy.scratchDirectoryName)
}.value
try await repository.createConversation(stored)
do {
try modelSelections?.persistFork(fork)
} catch {
try? await repository.deleteConversation(fork.id)
throw error
}
return fork
} catch {
try? await AgentScratchWorkspace(
conversationID: fork.id, applicationName: TermindAgentPolicy.scratchDirectoryName
).removeScope()
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)
}
}