Files
Termind/FrameworkTests/AssistantChatCollaborationEdgeTests.swift

216 lines
8.8 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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)
}
}