import AgentKit import Foundation import TermindDomain import TermindInfrastructure import Testing @testable import TermindApplication /// A run that never ends on its own, so a conversation stays steerable for as /// long as a test needs it to be. private struct HangingAgentModel: AgentModelStreaming { func stream( _ request: AgentModelRequest ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { continuation.yield(.textDelta("working")) try? await Task.sleep(for: .seconds(30)) continuation.finish() } } } } /// What the conversation does with a message typed at a working agent. /// /// The runtime's half of steering is covered in `AgentRuntimeTests`; this is the /// half the user sees — that the words leave the composer without pretending to /// be a turn, that they become one at the moment the run actually takes them, /// and that a run which ends first gives them back instead of eating them. @MainActor @Suite struct AgentSteeringTests { @Test func aQueuedDraftLeavesTheComposerWithoutEnteringEitherTranscript() { let conversation = ConversationModel() startRun(conversation) conversation.attach(ContextChip(kind: .output, label: "Last 20 lines", content: "boom")) conversation.composer.draft = " also tail the error log " conversation.submitSteeringDraft() #expect(conversation.composer.draft.isEmpty) // Consumed by the message they were attached to, exactly as a turn that // starts a run consumes them. #expect(conversation.composer.attachments.isEmpty) #expect(conversation.messages.isEmpty) #expect(conversation.transcript.isEmpty) #expect(conversation.pendingSteering.count == 1) let pending = conversation.pendingSteering[0] #expect(pending.draft == "also tail the error log") #expect(pending.attachments.count == 1) // The payload travels with it. A chip that only labelled something // would disclose nothing to the model. #expect(pending.message.text.contains("")) #expect(pending.message.text.contains("boom")) } @Test func deliveryMovesAQueuedMessageIntoBothTranscripts() { let conversation = ConversationModel() startRun(conversation) conversation.attach( ContextChip(kind: .pastedText, label: "Pasted text", content: "large context") ) conversation.composer.draft = "check nginx too" conversation.submitSteeringDraft() let queued = try! #require(conversation.pendingSteering.first).message // Restamped by the run, which is what puts the bubble after the reply // the user was reading when they typed it rather than before it. var delivered = queued delivered.createdAt = .now conversation.handle(.steeringDelivered(delivered)) #expect(conversation.pendingSteering.isEmpty) #expect(conversation.messages.count == 1) #expect(conversation.messages[0].role == .user) #expect(conversation.messages[0].id == queued.id) #expect(conversation.messages[0].text == "check nginx too") #expect(conversation.messages[0].contextChips.map(\.label) == ["Pasted text"]) #expect(!conversation.messages[0].text.contains("large context")) #expect(delivered.text.contains("large context")) #expect(conversation.messages[0].createdAt == delivered.createdAt) #expect(conversation.transcript == [delivered]) } @Test func stoppingARunHandsUndeliveredWordsBackToTheComposer() { let conversation = ConversationModel() startRun(conversation) conversation.attach(ContextChip(kind: .file, label: "nginx.conf", content: "server {}")) conversation.composer.draft = "first" conversation.submitSteeringDraft() conversation.composer.draft = "second" conversation.submitSteeringDraft() conversation.composer.draft = "third, still being typed" conversation.stop() #expect(conversation.pendingSteering.isEmpty) // In the order they were typed, with whatever was still in the box // last, so nothing has to be reassembled by hand. #expect(conversation.composer.draft == "first\n\nsecond\n\nthird, still being typed") // The composer gets the draft back, not the folded payload. #expect(!conversation.composer.draft.contains("