2485 lines
99 KiB
Swift
2485 lines
99 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
private struct ImmediateConversationAgentModel: AgentModelStreaming {
|
|
var turns: [[AgentModelStreamEvent]]
|
|
let cursor = ImmediateConversationCursor()
|
|
|
|
func stream(_ request: AgentModelRequest) -> AsyncThrowingStream<AgentModelStreamEvent, any Error> {
|
|
AsyncThrowingStream { continuation in
|
|
Task {
|
|
let index = await cursor.next()
|
|
for event in turns[min(index, turns.count - 1)] {
|
|
continuation.yield(event)
|
|
}
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor ImmediateConversationCursor {
|
|
var value = 0
|
|
func next() -> Int {
|
|
defer { value += 1 }
|
|
return value
|
|
}
|
|
}
|
|
|
|
private actor ConversationExecutionCounter {
|
|
var value = 0
|
|
func increment() { value += 1 }
|
|
}
|
|
|
|
private actor ConversationCancellationProbe {
|
|
var started = false
|
|
var cancelled = false
|
|
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private var cancellationWaiters: [UUID: CheckedContinuation<Void, any Error>] = [:]
|
|
|
|
func waitUntilStarted() async {
|
|
if started { return }
|
|
await withCheckedContinuation { startWaiters.append($0) }
|
|
}
|
|
|
|
func startAndWaitForCancellation() async throws {
|
|
started = true
|
|
let waiters = startWaiters
|
|
startWaiters.removeAll()
|
|
for waiter in waiters { waiter.resume() }
|
|
|
|
let id = UUID()
|
|
try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
cancellationWaiters[id] = continuation
|
|
if Task.isCancelled {
|
|
cancelWaiter(id)
|
|
}
|
|
}
|
|
} onCancel: {
|
|
Task { await self.cancelWaiter(id) }
|
|
}
|
|
}
|
|
|
|
private func cancelWaiter(_ id: UUID) {
|
|
guard let waiter = cancellationWaiters.removeValue(forKey: id) else { return }
|
|
cancelled = true
|
|
waiter.resume(throwing: CancellationError())
|
|
}
|
|
}
|
|
|
|
@Suite
|
|
@MainActor
|
|
struct ChatComposerStateTests {
|
|
@Test
|
|
func textEditorScrollerAppearsOnlyWhenItsDocumentOverflows() async throws {
|
|
let state = ComposerScrollerTestState()
|
|
let hosting = NSHostingView(
|
|
rootView: ComposerScrollerTestView(state: state)
|
|
.frame(width: 320, height: 80)
|
|
)
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 80),
|
|
styleMask: .borderless,
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.isReleasedWhenClosed = false
|
|
window.contentView = hosting
|
|
defer { window.close() }
|
|
|
|
let emptyScroller = try await requireComposerScroller(in: hosting)
|
|
emptyScroller.scrollerStyle = .legacy
|
|
emptyScroller.tile()
|
|
#expect(emptyScroller.autohidesScrollers)
|
|
#expect(emptyScroller.hasVerticalScroller)
|
|
#expect(emptyScroller.verticalScroller?.isHidden == true)
|
|
#expect(emptyScroller.verticalScroller?.isEnabled == false)
|
|
|
|
state.text = Array(repeating: "A line of composer text", count: 40).joined(separator: "\n")
|
|
let overflowingScroller = try await requireComposerScroller(in: hosting) {
|
|
$0.verticalScroller?.isEnabled == true
|
|
}
|
|
#expect(overflowingScroller.autohidesScrollers)
|
|
#expect(overflowingScroller.verticalScroller?.isHidden == false)
|
|
|
|
state.text = "Short again"
|
|
let shortScroller = try await requireComposerScroller(in: hosting) {
|
|
$0.verticalScroller?.isEnabled == false
|
|
}
|
|
#expect(shortScroller.verticalScroller?.isHidden == true)
|
|
}
|
|
|
|
@Test
|
|
func largePasteThresholdUsesCharactersOrLines() {
|
|
#expect(!ContextAttachmentImporter.shouldAttachPastedText(String(repeating: "x", count: 1_999)))
|
|
#expect(ContextAttachmentImporter.shouldAttachPastedText(String(repeating: "x", count: 2_000)))
|
|
#expect(
|
|
!ContextAttachmentImporter.shouldAttachPastedText(
|
|
Array(repeating: "line", count: 19).joined(separator: "\n")
|
|
))
|
|
#expect(
|
|
ContextAttachmentImporter.shouldAttachPastedText(
|
|
Array(repeating: "line", count: 20).joined(separator: "\n")
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func conversationOwnedFileImportFinishesAndKeepsFilename() async throws {
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension("txt")
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
try Data("attached through page drop".utf8).write(to: url)
|
|
let conversation = ConversationModel()
|
|
|
|
conversation.attachmentLoader = .live
|
|
conversation.importAttachmentFiles([url])
|
|
// The import reads the file off the main actor, so yielding only lets
|
|
// other main-actor work run and can return before the read finishes.
|
|
// Sleeping suspends until the reader actually reports back.
|
|
for _ in 0..<100 where !conversation.composer.pendingAttachments.isEmpty {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(conversation.composer.pendingAttachments.isEmpty)
|
|
#expect(conversation.composer.attachments.map(\.label) == [url.lastPathComponent])
|
|
#expect(conversation.composer.attachments.first?.content == "attached through page drop")
|
|
}
|
|
|
|
@Test
|
|
func conversationOwnedImportHonorsAttachmentLimitBeforeStartingTasks() {
|
|
let conversation = ConversationModel()
|
|
for index in 0..<ContextAttachmentImporter.maximumAttachments {
|
|
// Distinct content, not just distinct labels: `attach` deduplicates
|
|
// on kind and content, so chips that differ only by label collapse
|
|
// into one and the composer never reaches the limit under test.
|
|
conversation.attach(
|
|
ContextChip(kind: .output, label: "item \(index)", content: "item \(index)")
|
|
)
|
|
}
|
|
|
|
conversation.attachmentLoader = .live
|
|
conversation.importAttachmentFiles([URL(fileURLWithPath: "/tmp/extra.txt")])
|
|
|
|
#expect(conversation.composer.pendingAttachments.isEmpty)
|
|
#expect(conversation.composer.attachments.count == ContextAttachmentImporter.maximumAttachments)
|
|
#expect(conversation.composer.attachmentImportError != nil)
|
|
}
|
|
|
|
@Test
|
|
func imageAttachmentIsConsumedIntoVisibleAndModelFacingTurn() throws {
|
|
let image = AgentImageAttachment(
|
|
label: "graph.png", mimeType: "image/png", data: Data([9, 8, 7]),
|
|
pixelWidth: 40, pixelHeight: 30
|
|
)
|
|
let conversation = ConversationModel()
|
|
conversation.attach(ContextChip(kind: .image, label: image.label, image: image))
|
|
|
|
let sent = conversation.appendUserTurnContent("What does this show?")
|
|
|
|
#expect(sent.images == [image])
|
|
#expect(conversation.messages.last?.contextChips.map(\.label) == ["graph.png"])
|
|
#expect(conversation.messages.last?.images == [image])
|
|
#expect(conversation.transcript.last?.images == [image])
|
|
#expect(conversation.composer.attachments.isEmpty)
|
|
}
|
|
|
|
/// `/skill` has to reach the model without reaching the bubble: the user
|
|
/// typed a question, not a directive, and that is what message editing
|
|
/// reopens and what the title generator reads.
|
|
@Test
|
|
func anArmedSkillIsSentToTheModelButNotShownAsTypedText() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.composer.armedSkillName = "disk-triage"
|
|
|
|
let sent = conversation.appendUserTurnContent("root is at 99%")
|
|
|
|
#expect(sent.authoredText == "root is at 99%")
|
|
#expect(sent.text.contains("<use_skill name=\"disk-triage\">"))
|
|
#expect(sent.text.contains("skill_read_file"))
|
|
#expect(conversation.messages.last?.text == "root is at 99%")
|
|
#expect(conversation.transcript.last?.authoredText == "root is at 99%")
|
|
#expect(try #require(conversation.transcript.last).text.contains("disk-triage"))
|
|
// Consumed with the attachments, so the chip going away is how the user
|
|
// sees it went.
|
|
#expect(conversation.composer.armedSkillName == nil)
|
|
}
|
|
|
|
@Test
|
|
func anUnarmedComposerAddsNothingToTheModelFacingTurn() {
|
|
let conversation = ConversationModel()
|
|
let sent = conversation.appendUserTurnContent("root is at 99%")
|
|
|
|
#expect(sent.text == "root is at 99%")
|
|
#expect(sent.text == sent.authoredText)
|
|
}
|
|
|
|
@Test
|
|
func aManualAttachmentMakesAnEmptyDraftSendable() {
|
|
let conversation = ConversationModel()
|
|
#expect(!conversation.canSendDraft)
|
|
|
|
conversation.attach(
|
|
ContextChip(
|
|
kind: .output, label: "Last lines", content: "service failed"
|
|
))
|
|
#expect(conversation.canSendDraft)
|
|
|
|
conversation.sendDraft()
|
|
|
|
#expect(conversation.messages.last?.text == "")
|
|
#expect(conversation.messages.last?.contextChips.map(\.label) == ["Last lines"])
|
|
#expect(conversation.transcript.last?.text.contains("service failed") == true)
|
|
#expect(
|
|
conversation.transcript.last?.contextAttachments.first?.data
|
|
== Data("service failed".utf8))
|
|
#expect(conversation.composer.attachments.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func imageOnlyTurnIsValidAndSuppliesItsFallbackTitle() {
|
|
let image = AgentImageAttachment(
|
|
label: "dashboard.png", mimeType: "image/png", data: Data([1, 2, 3]),
|
|
pixelWidth: 10, pixelHeight: 10
|
|
)
|
|
let conversation = ConversationModel()
|
|
conversation.attach(ContextChip(kind: .image, label: image.label, image: image))
|
|
|
|
conversation.sendDraft()
|
|
|
|
#expect(conversation.transcript.last?.text == "")
|
|
#expect(conversation.transcript.last?.images == [image])
|
|
#expect(conversation.derivedTitle == "dashboard.png")
|
|
}
|
|
|
|
@Test
|
|
func legacyFileReferenceFallsBackToExtractedText() throws {
|
|
let text = "\n\n<file label=\"manual.pdf\">\nExtracted body\n</file>"
|
|
let reference = try #require(ContextChip.visibleReferences(in: text).first)
|
|
|
|
#expect(reference.attachment?.suggestedFilename == "manual extracted.txt")
|
|
#expect(reference.attachment?.isOriginalFile == false)
|
|
#expect(reference.attachment?.data == Data("Extracted body".utf8))
|
|
}
|
|
|
|
@Test
|
|
func activeFormKeysYieldToImeAndConsumeOnlyPlainReturnOrEscape() {
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 36, hasModifiers: false, hasMarkedText: false,
|
|
isRepeat: false, isDefaultEnabled: true
|
|
) == .submit)
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 53, hasModifiers: false, hasMarkedText: false,
|
|
isRepeat: false, isDefaultEnabled: false
|
|
) == .cancel)
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 76, hasModifiers: false, hasMarkedText: false,
|
|
isRepeat: false, isDefaultEnabled: true
|
|
) == .submit)
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 36, hasModifiers: false, hasMarkedText: true,
|
|
isRepeat: false, isDefaultEnabled: true
|
|
) == .passThrough)
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 36, hasModifiers: false, hasMarkedText: false,
|
|
isRepeat: false, isDefaultEnabled: false
|
|
) == .consume)
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 36, hasModifiers: false, hasMarkedText: false,
|
|
isRepeat: true, isDefaultEnabled: true
|
|
) == .consume)
|
|
#expect(
|
|
ActiveFormKeyDecision.resolve(
|
|
keyCode: 36, hasModifiers: true, hasMarkedText: false,
|
|
isRepeat: false, isDefaultEnabled: true
|
|
) == .passThrough)
|
|
}
|
|
|
|
@Test
|
|
func sidebarEscapeYieldsToAVisibleDecisionForm() {
|
|
#expect(
|
|
!AssistantSidebarEscapePolicy.shouldCloseConversation(
|
|
hasConversation: true,
|
|
hasBlockingInteraction: true
|
|
))
|
|
#expect(
|
|
AssistantSidebarEscapePolicy.shouldCloseConversation(
|
|
hasConversation: true,
|
|
hasBlockingInteraction: false
|
|
))
|
|
#expect(
|
|
!AssistantSidebarEscapePolicy.shouldCloseConversation(
|
|
hasConversation: false,
|
|
hasBlockingInteraction: false
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func reasoningEffortStaysScopedToItsConversation() {
|
|
let first = ConversationModel()
|
|
let second = ConversationModel()
|
|
|
|
first.reasoningEffort = .max
|
|
|
|
#expect(first.reasoningEffort == .max)
|
|
#expect(second.reasoningEffort == .medium)
|
|
}
|
|
|
|
@Test
|
|
func modelSelectionStillFeedsUnsetConversationsWithoutOverridingExplicitOnes() {
|
|
let firstModel = AIModel(id: "first")
|
|
let secondModel = AIModel(id: "second")
|
|
let provider = ModelProvider(
|
|
name: "Test Provider",
|
|
models: [firstModel, secondModel]
|
|
)
|
|
let store = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false)
|
|
let firstRef = AIModelRef(modelProviderID: provider.id, modelID: firstModel.id)
|
|
let secondRef = AIModelRef(modelProviderID: provider.id, modelID: secondModel.id)
|
|
|
|
#expect(store.resolve(conversation: nil)?.model.id == firstModel.id)
|
|
|
|
store.select(secondRef)
|
|
|
|
#expect(store.resolve(conversation: nil)?.model.id == secondModel.id)
|
|
#expect(store.resolve(conversation: firstRef)?.model.id == firstModel.id)
|
|
}
|
|
|
|
@Test
|
|
func messageEventsUpsertWhenAnEarlierStreamingEventWasMissed() {
|
|
let conversation = ConversationModel()
|
|
let deltaID = UUID()
|
|
conversation.handle(.messageDelta(messageID: deltaID, text: "streamed"))
|
|
|
|
#expect(conversation.messages.count == 1)
|
|
#expect(conversation.messages[0].id == deltaID)
|
|
#expect(conversation.messages[0].text == "streamed")
|
|
#expect(conversation.messages[0].isStreaming)
|
|
|
|
let finishedID = UUID()
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
id: finishedID, role: .assistant, text: "finished"
|
|
)))
|
|
|
|
#expect(conversation.messages.count == 2)
|
|
#expect(conversation.messages[1].id == finishedID)
|
|
#expect(conversation.messages[1].text == "finished")
|
|
#expect(!conversation.messages[1].isStreaming)
|
|
}
|
|
|
|
@Test
|
|
func stoppingBeforeAnswerHidesTheWorkingPlaceholder() throws {
|
|
let (conversation, _, assistant) = beginStreamingConversation()
|
|
|
|
conversation.stop()
|
|
|
|
let stoppedMessage = try #require(
|
|
conversation.messages.first { $0.id == assistant.id }
|
|
)
|
|
#expect(!stoppedMessage.isStreaming)
|
|
#expect(conversation.runState == .cancelled)
|
|
#expect(conversation.timeline.map(timelineKind) == ["user", "work"])
|
|
#expect(conversation.workGroups.first?.run.state == .cancelled)
|
|
}
|
|
|
|
@Test
|
|
func stoppingDuringReasoningEndsItsActivePresentation() throws {
|
|
let (conversation, _, assistant) = beginStreamingConversation()
|
|
conversation.handle(
|
|
.reasoningDelta(
|
|
messageID: assistant.id, text: "Evaluating the request."
|
|
))
|
|
#expect(conversation.workGroups.first?.activeReasoningBlockID != nil)
|
|
|
|
conversation.stop()
|
|
|
|
let stoppedMessage = try #require(
|
|
conversation.messages.first { $0.id == assistant.id }
|
|
)
|
|
#expect(!stoppedMessage.isStreaming)
|
|
#expect(stoppedMessage.reasoning.map(\.text) == ["Evaluating the request."])
|
|
#expect(conversation.workGroups.first?.activeReasoningBlockID == nil)
|
|
#expect(conversation.timeline.map(timelineKind) == ["user", "work"])
|
|
}
|
|
|
|
@Test
|
|
func stoppingAPartialAnswerKeepsItsTextWithoutAStreamingCaret() throws {
|
|
let (conversation, _, assistant) = beginStreamingConversation()
|
|
conversation.handle(.messageDelta(messageID: assistant.id, text: "Partial answer"))
|
|
|
|
conversation.stop()
|
|
|
|
let stoppedMessage = try #require(
|
|
conversation.messages.first { $0.id == assistant.id }
|
|
)
|
|
#expect(stoppedMessage.text == "Partial answer")
|
|
#expect(!stoppedMessage.isStreaming)
|
|
#expect(conversation.timeline.map(timelineKind) == ["user", "assistant", "work"])
|
|
}
|
|
|
|
@Test
|
|
func stoppingAWorkingToolInterruptsItAndSettlesItsMessage() throws {
|
|
let (conversation, runID, assistant) = beginStreamingConversation()
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(id: "call-1", name: "lookup", arguments: .object([:])),
|
|
sourceMessageID: assistant.id
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)))
|
|
conversation.handle(.toolStarted(invocation))
|
|
|
|
conversation.stop()
|
|
|
|
#expect(conversation.toolCards.first?.state == .interrupted)
|
|
#expect(conversation.messages.allSatisfy { !$0.isStreaming })
|
|
#expect(conversation.workGroups.first?.run.state == .cancelled)
|
|
}
|
|
|
|
@Test
|
|
func workingPlaceholderDisappearsAsSoonAsAToolIsVisible() throws {
|
|
let (conversation, runID, assistant) = beginStreamingConversation()
|
|
let before = try #require(conversation.workGroups.first)
|
|
#expect(before.items.contains { if case .message = $0 { true } else { false } })
|
|
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
id: assistant.id,
|
|
role: .assistant,
|
|
toolCalls: [
|
|
AgentToolCall(
|
|
id: "call-1", name: "lookup", arguments: .object([:])
|
|
)
|
|
],
|
|
createdAt: assistant.createdAt
|
|
)))
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(id: "call-1", name: "lookup", arguments: .object([:])),
|
|
sourceMessageID: assistant.id
|
|
)
|
|
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)))
|
|
|
|
let after = try #require(conversation.workGroups.first)
|
|
#expect(!after.items.contains { if case .message = $0 { true } else { false } })
|
|
guard case .tools(let batch) = try #require(after.items.first) else {
|
|
Issue.record("Expected the visible tool to replace Working")
|
|
return
|
|
}
|
|
#expect(batch.cards.map(\.id) == [invocation.id])
|
|
}
|
|
|
|
@Test
|
|
func existingWorkOutputSuppressesLaterWorkingButStillAcceptsReasoning() throws {
|
|
let conversation = ConversationModel()
|
|
let runID = UUID()
|
|
let first = AgentTranscriptMessage(
|
|
role: .assistant,
|
|
toolCalls: [
|
|
AgentToolCall(
|
|
id: "call-1", name: "lookup", arguments: .object([:])
|
|
)
|
|
]
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: first.createdAt
|
|
)))
|
|
conversation.handle(.messageStarted(first))
|
|
conversation.handle(.messageFinished(first))
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID,
|
|
call: first.toolCalls[0],
|
|
sourceMessageID: first.id
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)))
|
|
|
|
let next = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(.turnStarted(index: 1))
|
|
conversation.handle(.messageStarted(next))
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
!group.items.contains { item in
|
|
guard case .message(let message) = item else { return false }
|
|
return message.id == next.id
|
|
})
|
|
#expect(group.items.contains { if case .tools = $0 { true } else { false } })
|
|
|
|
conversation.handle(
|
|
.reasoningDelta(
|
|
messageID: next.id,
|
|
text: "Evaluate the first result."
|
|
))
|
|
let updated = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
!updated.items.contains { item in
|
|
guard case .message(let message) = item else { return false }
|
|
return message.id == next.id
|
|
})
|
|
guard
|
|
case .tools(let batch) = try #require(
|
|
updated.items.first(where: { if case .tools = $0 { true } else { false } })
|
|
)
|
|
else {
|
|
Issue.record("Expected reasoning to move into the tool title")
|
|
return
|
|
}
|
|
#expect(batch.reasoning?.sourceMessageID == next.id)
|
|
#expect(batch.reasoning?.markdown == "Evaluate the first result.")
|
|
}
|
|
|
|
@Test
|
|
func reasoningAloneDoesNotSuppressTheNextWorkingPlaceholder() throws {
|
|
let conversation = ConversationModel()
|
|
let runID = UUID()
|
|
let first = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: first.createdAt
|
|
)))
|
|
conversation.handle(.messageStarted(first))
|
|
conversation.handle(
|
|
.reasoningDelta(
|
|
messageID: first.id,
|
|
text: "First reasoning block."
|
|
))
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
id: first.id,
|
|
role: .assistant,
|
|
reasoning: [AgentReasoningBlock(text: "First reasoning block.")],
|
|
createdAt: first.createdAt
|
|
)))
|
|
conversation.handle(.turnFinished(index: 0, stopReason: .completed))
|
|
|
|
let next = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(.turnStarted(index: 1))
|
|
conversation.handle(.messageStarted(next))
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
group.items.contains { item in
|
|
guard case .message(let message) = item else { return false }
|
|
return message.id == next.id && message.text.isEmpty
|
|
})
|
|
}
|
|
|
|
@Test
|
|
func newReasoningRemainsVisibleAfterTheExistingToolBatchIsTerminal() throws {
|
|
let conversation = ConversationModel()
|
|
let runID = UUID()
|
|
let source = AgentTranscriptMessage(
|
|
role: .assistant,
|
|
toolCalls: [
|
|
AgentToolCall(
|
|
id: "call-1", name: "lookup", arguments: .object([:])
|
|
)
|
|
]
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: source.createdAt
|
|
)))
|
|
conversation.handle(.messageFinished(source))
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID, call: source.toolCalls[0], sourceMessageID: source.id
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
invocation,
|
|
AgentToolResult(callID: invocation.call.id, content: "ok")
|
|
))
|
|
|
|
let next = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(.turnStarted(index: 1))
|
|
conversation.handle(.messageStarted(next))
|
|
let waiting = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
!waiting.items.contains {
|
|
if case .message = $0 { true } else { false }
|
|
})
|
|
|
|
conversation.handle(
|
|
.reasoningDelta(
|
|
messageID: next.id,
|
|
text: "Reason about the completed result."
|
|
))
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
group.items.contains { item in
|
|
guard case .reasoning(let block) = item else { return false }
|
|
return block.text == "Reason about the completed result."
|
|
})
|
|
guard
|
|
case .tools(let batch) = try #require(
|
|
group.items.first(where: { if case .tools = $0 { true } else { false } })
|
|
)
|
|
else {
|
|
Issue.record("Expected the completed tool batch to remain")
|
|
return
|
|
}
|
|
#expect(batch.reasoning == nil)
|
|
}
|
|
|
|
@Test
|
|
func terminalRunEventSettlesAnUnfinishedMessage() {
|
|
let (conversation, runID, assistant) = beginStreamingConversation()
|
|
|
|
conversation.handle(
|
|
.runFinished(
|
|
AgentRunSummary(
|
|
id: runID, state: .failed, startedAt: .now, finishedAt: .now
|
|
)))
|
|
|
|
#expect(conversation.messages.first { $0.id == assistant.id }?.isStreaming == false)
|
|
}
|
|
|
|
@Test
|
|
func everyRawDeltaIsImmediatelyVisibleWhileContextPublishingStaysThrottled() {
|
|
let conversation = ConversationModel(
|
|
contextEstimatePublishInterval: .seconds(60)
|
|
)
|
|
let assistant = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(.messageStarted(assistant))
|
|
|
|
for _ in 0..<1_000 {
|
|
conversation.handle(.messageDelta(messageID: assistant.id, text: "x"))
|
|
}
|
|
|
|
#expect(
|
|
conversation.messages.first { $0.id == assistant.id }?.text
|
|
== String(repeating: "x", count: 1_000)
|
|
)
|
|
#expect(conversation.contextEstimatePublishCount == 1)
|
|
|
|
let final = AgentTranscriptMessage(
|
|
id: assistant.id, role: .assistant,
|
|
text: String(repeating: "x", count: 1_000),
|
|
createdAt: assistant.createdAt
|
|
)
|
|
conversation.handle(.messageFinished(final))
|
|
#expect(conversation.messages.first { $0.id == assistant.id }?.text == final.text)
|
|
#expect(conversation.contextEstimatePublishCount == 2)
|
|
#expect(
|
|
conversation.contextEstimate
|
|
== AgentContextEstimator.estimate(
|
|
systemPrompt: TermindAgentPolicy.systemPrompt,
|
|
toolSchemaCharacters: 0,
|
|
transcript: [final],
|
|
attachedCharacters: 0,
|
|
measured: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func everyReasoningDeltaIsImmediatelyVisible() {
|
|
let conversation = ConversationModel()
|
|
let assistant = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(.messageStarted(assistant))
|
|
|
|
conversation.handle(.reasoningDelta(messageID: assistant.id, text: "first"))
|
|
conversation.handle(.reasoningDelta(messageID: assistant.id, text: " tail"))
|
|
|
|
#expect(
|
|
conversation.messages.first { $0.id == assistant.id }?.reasoning.map(\.text)
|
|
== ["first tail"]
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func immediatelyCompletedRuntimeStillReachesTheConversation() async {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
let runtime = AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.textDelta("partial"), .textSnapshot("visible immediately"),
|
|
.finished(.completed),
|
|
]
|
|
]),
|
|
tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
)
|
|
|
|
conversation.start(
|
|
runtime: runtime,
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "hello",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository)
|
|
await waitForTerminal(conversation)
|
|
|
|
#expect(conversation.runState == .completed)
|
|
#expect(
|
|
conversation.messages.contains {
|
|
$0.role == .assistant && $0.text == "visible immediately"
|
|
})
|
|
}
|
|
|
|
@Test
|
|
func toolCallContinuesToTheNextVisibleAssistantTurn() async {
|
|
let executions = ConversationExecutionCounter()
|
|
let model = ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.toolCallDelta(id: "call-1", name: "lookup", arguments: #"{"stale":true}"#),
|
|
.toolCallSnapshot(
|
|
id: "call-1", providerItemID: "item-1", name: "lookup",
|
|
arguments: #"{"query":"hosts"}"#
|
|
),
|
|
// Compatible gateways sometimes report a generic completion
|
|
// even though the assistant output contains a tool call. The
|
|
// loop must follow the message content, not this hint.
|
|
.finished(.completed),
|
|
],
|
|
[.textDelta("tool result is visible"), .finished(.completed)],
|
|
])
|
|
let tool = AnyAgentTool(
|
|
descriptor: AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup",
|
|
inputSchema: .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"query": .object(["type": .string("string")])
|
|
]),
|
|
"required": .array([.string("query")]),
|
|
"additionalProperties": .bool(false),
|
|
]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
) { invocation, _ in
|
|
#expect(invocation.call.arguments == .object(["query": .string("hosts")]))
|
|
await executions.increment()
|
|
return AgentToolResult(callID: invocation.call.id, content: "host-a")
|
|
}
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
let runtime = AgentRuntime(
|
|
model: model, tools: [tool],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
)
|
|
|
|
conversation.start(
|
|
runtime: runtime,
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "inspect",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository)
|
|
await waitForTerminal(conversation)
|
|
|
|
#expect(await executions.value == 1)
|
|
#expect(conversation.runState == .completed)
|
|
#expect(
|
|
conversation.messages.contains {
|
|
$0.role == .assistant && $0.text == "tool result is visible"
|
|
})
|
|
}
|
|
|
|
/// `messages` is a display projection with no tool calls and no tool
|
|
/// results. Replaying it as the next run's prior context dropped everything
|
|
/// the agent had already done, and left an assistant turn with empty content
|
|
/// — the shape Anthropic and Google answer with a 400.
|
|
@Test
|
|
func modelFacingHistoryKeepsToolCallsAndResults() async {
|
|
let model = ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.toolCallSnapshot(
|
|
id: "call-1", providerItemID: "item-1", name: "lookup",
|
|
arguments: #"{"query":"hosts"}"#
|
|
),
|
|
.finished(.toolCalls),
|
|
],
|
|
[.textDelta("host-a is up"), .finished(.completed)],
|
|
])
|
|
let tool = AnyAgentTool(
|
|
descriptor: AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup",
|
|
inputSchema: .object([
|
|
"type": .string("object"),
|
|
"properties": .object(["query": .object(["type": .string("string")])]),
|
|
"required": .array([.string("query")]),
|
|
"additionalProperties": .bool(false),
|
|
]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
) { invocation, _ in
|
|
AgentToolResult(callID: invocation.call.id, content: "host-a")
|
|
}
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
conversation.appendUserTurn("inspect")
|
|
// The prompt travels on the request, so it must not also be replayed.
|
|
#expect(conversation.pendingPriorMessages.isEmpty)
|
|
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: model, tools: [tool],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "inspect",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
|
|
conversation.appendUserTurn("connect to it")
|
|
let prior = conversation.pendingPriorMessages
|
|
|
|
#expect(prior.map(\.role) == [.user, .assistant, .tool, .assistant])
|
|
let call = prior.first { $0.role == .assistant }?.toolCalls.first
|
|
#expect(call?.name == "lookup")
|
|
#expect(call?.providerItemID == "item-1")
|
|
#expect(
|
|
prior.contains {
|
|
$0.role == .tool && $0.toolCallID == "call-1" && $0.text == "host-a"
|
|
})
|
|
#expect(!prior.contains { $0.role == .assistant && $0.text.isEmpty && $0.toolCalls.isEmpty })
|
|
}
|
|
|
|
/// Stop released the runtime reference before the cancelling task read it,
|
|
/// so the run carried on executing tools with nobody listening.
|
|
@Test
|
|
func stopCancelsTheRunAndNotOnlyItsEventStream() async throws {
|
|
let probe = ConversationCancellationProbe()
|
|
let tool = AnyAgentTool(
|
|
descriptor: AgentToolDescriptor(
|
|
name: "wait", summary: "wait",
|
|
inputSchema: .object([
|
|
"type": .string("object"), "properties": .object([:]),
|
|
"required": .array([]), "additionalProperties": .bool(false),
|
|
]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
) { invocation, _ in
|
|
try await probe.startAndWaitForCancellation()
|
|
return AgentToolResult(callID: invocation.call.id, content: "never")
|
|
}
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
conversation.appendUserTurn("wait for me")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.toolCallDelta(id: "call-1", name: "wait", arguments: "{}"),
|
|
.finished(.toolCalls),
|
|
]
|
|
]),
|
|
tools: [tool],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "wait for me",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await probe.waitUntilStarted()
|
|
#expect(await probe.started)
|
|
await wait { conversation.workGroups.first?.run != nil }
|
|
#expect(conversation.workGroups.first?.run != nil)
|
|
|
|
conversation.stop()
|
|
|
|
await wait { await probe.cancelled }
|
|
#expect(await probe.cancelled)
|
|
#expect(conversation.runState == .cancelled)
|
|
#expect(!conversation.isStreaming)
|
|
let stoppedRun = try #require(conversation.workGroups.first?.run)
|
|
#expect(stoppedRun.state == .cancelled)
|
|
#expect(stoppedRun.finishedAt != nil)
|
|
}
|
|
|
|
/// Tool-producing prose and its cards belong to one disclosure before the
|
|
/// final answer, instead of being ordered by when provider events arrived.
|
|
@Test
|
|
func timelineInterleavesToolCardsWithTheMessagesTheyBelongTo() async {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
conversation.appendUserTurn("inspect")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.textDelta("Checking."),
|
|
.toolCallDelta(id: "call-1", name: "lookup", arguments: "{}"),
|
|
.finished(.toolCalls),
|
|
],
|
|
[.textDelta("host-a is up"), .finished(.completed)],
|
|
]),
|
|
tools: [
|
|
AnyAgentTool(
|
|
descriptor: AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup",
|
|
inputSchema: .object([
|
|
"type": .string("object"), "properties": .object([:]),
|
|
"required": .array([]), "additionalProperties": .bool(false),
|
|
]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
) { invocation, _ in
|
|
AgentToolResult(callID: invocation.call.id, content: "host-a")
|
|
}
|
|
],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "inspect",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
|
|
let kinds = conversation.timeline.map { item in
|
|
switch item {
|
|
case .message(let message): message.role == .user ? "user" : "assistant"
|
|
case .tool: "tool"
|
|
case .work: "work"
|
|
case .command: "command"
|
|
case .plan: "plan"
|
|
case .compaction: "compaction"
|
|
case .compactionActivity: "compaction-activity"
|
|
case .failure: "failure"
|
|
}
|
|
}
|
|
#expect(kinds == ["user", "work", "assistant"])
|
|
let work = conversation.workGroups.first
|
|
#expect(work?.items.count == 2)
|
|
#expect(work?.run.state == .completed)
|
|
}
|
|
|
|
@Test
|
|
func toolBatchPublishesAtomicallyAndStaysStableAcrossCompletion() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("run both")
|
|
let runID = UUID()
|
|
let start = Date()
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: start
|
|
)))
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
let source = AgentTranscriptMessage(
|
|
role: .assistant,
|
|
toolCalls: [
|
|
AgentToolCall(id: "first", name: "lookup", arguments: .object([:])),
|
|
AgentToolCall(id: "second", name: "lookup", arguments: .object([:])),
|
|
],
|
|
createdAt: start.addingTimeInterval(0.5)
|
|
)
|
|
conversation.handle(.messageFinished(source))
|
|
let first = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(id: "first", name: "lookup", arguments: .object([:])),
|
|
sourceMessageID: source.id,
|
|
createdAt: start.addingTimeInterval(1)
|
|
)
|
|
let second = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(id: "second", name: "lookup", arguments: .object([:])),
|
|
sourceMessageID: source.id,
|
|
createdAt: start.addingTimeInterval(2)
|
|
)
|
|
conversation.handle(.toolProposed(first, descriptor))
|
|
#expect(conversation.workGroups.isEmpty)
|
|
conversation.handle(
|
|
.toolFinished(
|
|
first,
|
|
AgentToolResult(
|
|
callID: first.call.id, content: "failed", isError: true
|
|
)))
|
|
#expect(conversation.workGroups.isEmpty)
|
|
|
|
conversation.handle(.toolProposed(second, descriptor))
|
|
guard case .tools(let live) = try #require(conversation.workGroups.first?.items.first)
|
|
else {
|
|
Issue.record("Two adjacent tools should form one live batch")
|
|
return
|
|
}
|
|
#expect(live.cards.map(\.id) == [first.id, second.id])
|
|
#expect(live.cards.map(\.state) == [.failed, .proposed])
|
|
|
|
conversation.handle(
|
|
.toolFinished(
|
|
second,
|
|
AgentToolResult(
|
|
callID: second.call.id, content: "ok"
|
|
)))
|
|
guard
|
|
case .tools(let terminal) = try #require(
|
|
conversation.workGroups.first?.items.first
|
|
)
|
|
else {
|
|
Issue.record("A failed member must not split the completed batch")
|
|
return
|
|
}
|
|
#expect(terminal.cards.map(\.state) == [.failed, .completed])
|
|
}
|
|
|
|
@Test
|
|
func providerNativeToolsPublishAtomicallyWhenTheirTurnFinishes() throws {
|
|
let conversation = ConversationModel()
|
|
let runID = UUID()
|
|
let source = AgentTranscriptMessage(role: .assistant)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: source.createdAt
|
|
)))
|
|
conversation.handle(.messageStarted(source))
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "web_search", summary: "search", inputSchema: .object([:]),
|
|
target: .network, approvalPolicy: .approve
|
|
)
|
|
let invocations = ["first", "second"].map { id in
|
|
AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(
|
|
id: id, name: "web_search",
|
|
arguments: .object(["query": .string(id)])
|
|
),
|
|
sourceMessageID: source.id
|
|
)
|
|
}
|
|
for invocation in invocations {
|
|
conversation.handle(.toolProposed(invocation, descriptor))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
invocation,
|
|
AgentToolResult(
|
|
callID: invocation.call.id,
|
|
content: "[]",
|
|
metadata: [AgentToolResult.providerNativeKey: .bool(true)]
|
|
)))
|
|
#expect(conversation.toolCards.isEmpty)
|
|
}
|
|
|
|
conversation.handle(.messageFinished(source))
|
|
|
|
#expect(conversation.toolCards.map(\.id) == invocations.map(\.id))
|
|
guard
|
|
case .tools(let batch) = try #require(
|
|
conversation.workGroups.first?.items.first
|
|
)
|
|
else {
|
|
Issue.record("Expected one provider-native batch")
|
|
return
|
|
}
|
|
#expect(batch.cards.map(\.state) == [.completed, .completed])
|
|
}
|
|
|
|
@Test
|
|
func liveToolBatchReusesOnlyItsProviderTurnsReasoning() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("inspect both")
|
|
let runID = UUID()
|
|
let start = Date()
|
|
let source = AgentTranscriptMessage(
|
|
role: .assistant,
|
|
reasoning: [AgentReasoningBlock(text: "**Compare** both hosts.\nThen verify them.")],
|
|
toolCalls: ["first", "second"].map {
|
|
AgentToolCall(id: $0, name: "lookup", arguments: .object([:]))
|
|
},
|
|
createdAt: start.addingTimeInterval(1)
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: start
|
|
)))
|
|
conversation.handle(.messageStarted(source))
|
|
conversation.handle(.messageFinished(source))
|
|
conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls))
|
|
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
let invocations = ["first", "second"].enumerated().map { offset, callID in
|
|
AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(id: callID, name: "lookup", arguments: .object([:])),
|
|
sourceMessageID: source.id,
|
|
createdAt: start.addingTimeInterval(Double(offset + 2))
|
|
)
|
|
}
|
|
for invocation in invocations {
|
|
conversation.handle(.toolProposed(invocation, descriptor))
|
|
}
|
|
|
|
guard
|
|
case .tools(let live) = try #require(
|
|
conversation.workGroups.first?.items.first
|
|
)
|
|
else {
|
|
Issue.record("Expected a live tool batch")
|
|
return
|
|
}
|
|
#expect(live.runID == runID)
|
|
#expect(live.reasoning?.sourceMessageID == source.id)
|
|
#expect(live.reasoning?.markdown == "**Compare** both hosts.\nThen verify them.")
|
|
|
|
for invocation in invocations {
|
|
conversation.handle(
|
|
.toolFinished(
|
|
invocation,
|
|
AgentToolResult(
|
|
callID: invocation.call.id, content: "ok"
|
|
)))
|
|
}
|
|
guard
|
|
case .tools(let terminal) = try #require(
|
|
conversation.workGroups.first?.items.first
|
|
)
|
|
else {
|
|
Issue.record("Expected the completed tools to remain batched")
|
|
return
|
|
}
|
|
#expect(terminal.reasoning == nil)
|
|
}
|
|
|
|
@Test
|
|
func liveToolBatchOwnsCurrentlyStreamingReasoningWithoutADuplicateRow() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("inspect both")
|
|
let runID = UUID()
|
|
let start = Date()
|
|
let reasoning = AgentReasoningBlock(text: "Compare both hosts.")
|
|
let source = AgentTranscriptMessage(
|
|
role: .assistant, reasoning: [reasoning],
|
|
toolCalls: ["first", "second"].map {
|
|
AgentToolCall(id: $0, name: "lookup", arguments: .object([:]))
|
|
},
|
|
createdAt: start.addingTimeInterval(1)
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: start
|
|
)))
|
|
conversation.handle(.messageStarted(source))
|
|
conversation.handle(
|
|
.reasoningDelta(
|
|
messageID: source.id, text: reasoning.text
|
|
))
|
|
conversation.handle(.messageFinished(source))
|
|
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
for (offset, call) in source.toolCalls.enumerated() {
|
|
conversation.handle(
|
|
.toolProposed(
|
|
AgentToolInvocation(
|
|
runID: runID, call: call, sourceMessageID: source.id,
|
|
createdAt: start.addingTimeInterval(Double(offset + 2))
|
|
), descriptor))
|
|
}
|
|
|
|
let overlappingGroup = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
!overlappingGroup.items.contains { item in
|
|
guard case .reasoning = item else { return false }
|
|
return true
|
|
})
|
|
guard
|
|
case .tools(let overlappingBatch) = try #require(
|
|
overlappingGroup.items.first(where: {
|
|
if case .tools = $0 { return true }
|
|
return false
|
|
})
|
|
)
|
|
else {
|
|
Issue.record("Expected a live tool batch")
|
|
return
|
|
}
|
|
#expect(overlappingBatch.reasoning?.markdown == reasoning.text)
|
|
|
|
conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls))
|
|
let settledGroup = try #require(conversation.workGroups.first)
|
|
#expect(
|
|
!settledGroup.items.contains { item in
|
|
guard case .reasoning = item else { return false }
|
|
return true
|
|
})
|
|
guard
|
|
case .tools(let settledBatch) = try #require(
|
|
settledGroup.items.first(where: {
|
|
if case .tools = $0 { return true }
|
|
return false
|
|
})
|
|
)
|
|
else {
|
|
Issue.record("Expected the live tool batch to remain")
|
|
return
|
|
}
|
|
#expect(settledBatch.reasoning?.markdown == reasoning.text)
|
|
}
|
|
|
|
@Test
|
|
func directAnswerDoesNotCreateAWorkGroup() {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("hello")
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
role: .assistant, text: "Hello!"
|
|
)))
|
|
|
|
#expect(conversation.workGroups.isEmpty)
|
|
#expect(conversation.timeline.count == 2)
|
|
}
|
|
|
|
@Test
|
|
func finishedReasoningOnlyAnswerLeavesNoWorkedHistory() async {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
conversation.appendUserTurn("compare")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.reasoningDelta("Compared the two configurations."),
|
|
.textDelta("Use the second configuration."),
|
|
.finished(.completed),
|
|
]
|
|
]),
|
|
tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "compare",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
|
|
let order = conversation.timeline.map { item in
|
|
switch item {
|
|
case .message(let message): return message.role == .user ? "user" : "assistant"
|
|
case .work: return "work"
|
|
default: return "other"
|
|
}
|
|
}
|
|
#expect(order == ["user", "assistant"])
|
|
#expect(conversation.workGroups.isEmpty)
|
|
#expect(conversation.messages.last?.text == "Use the second configuration.")
|
|
#expect(
|
|
conversation.messages.last?.reasoning.map(\.text)
|
|
== ["Compared the two configurations."])
|
|
}
|
|
|
|
@Test
|
|
func liveReasoningReplacesTheGenericWorkingIndicator() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("think")
|
|
let runID = UUID()
|
|
let startedAt = Date()
|
|
let assistant = AgentTranscriptMessage(
|
|
role: .assistant, createdAt: startedAt.addingTimeInterval(1)
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: startedAt
|
|
)))
|
|
conversation.handle(.messageStarted(assistant))
|
|
|
|
let placeholderGroup = try #require(conversation.workGroups.first)
|
|
guard case .message(let placeholder) = try #require(placeholderGroup.items.first) else {
|
|
Issue.record("Expected Working in the future reasoning slot")
|
|
return
|
|
}
|
|
#expect(placeholder.id == assistant.id)
|
|
#expect(placeholder.text.isEmpty)
|
|
#expect(
|
|
conversation.timeline.compactMap { item -> UUID? in
|
|
guard case .message(let message) = item, message.role == .assistant else {
|
|
return nil
|
|
}
|
|
return message.id
|
|
}.isEmpty)
|
|
|
|
conversation.handle(
|
|
.reasoningDelta(
|
|
messageID: assistant.id, text: "Evaluating the request."
|
|
))
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
#expect(group.id == placeholderGroup.id)
|
|
guard case .reasoning(let block) = try #require(group.items.first) else {
|
|
Issue.record("Expected live reasoning in Worked")
|
|
return
|
|
}
|
|
#expect(group.activeReasoningBlockID == block.id)
|
|
let visibleAssistantRows = conversation.timeline.compactMap { item -> ChatMessage? in
|
|
guard case .message(let message) = item, message.role == .assistant else {
|
|
return nil
|
|
}
|
|
return message
|
|
}
|
|
#expect(visibleAssistantRows.isEmpty)
|
|
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
id: assistant.id, role: .assistant, reasoning: [block],
|
|
createdAt: assistant.createdAt
|
|
)))
|
|
conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls))
|
|
#expect(conversation.workGroups.isEmpty)
|
|
|
|
// A new model turn gets its own ordinary Working placeholder until it
|
|
// produces reasoning. The previous turn's reasoning never returns.
|
|
conversation.handle(.turnStarted(index: 1))
|
|
let continuation = AgentTranscriptMessage(
|
|
role: .assistant, createdAt: startedAt.addingTimeInterval(2)
|
|
)
|
|
conversation.handle(.messageStarted(continuation))
|
|
let continuationGroup = try #require(conversation.workGroups.first)
|
|
let placeholderIDs = continuationGroup.items.compactMap { item -> UUID? in
|
|
guard case .message(let message) = item else { return nil }
|
|
return message.id
|
|
}
|
|
#expect(placeholderIDs == [continuation.id])
|
|
#expect(
|
|
conversation.timeline.compactMap { item -> UUID? in
|
|
guard case .message(let message) = item, message.role == .assistant else {
|
|
return nil
|
|
}
|
|
return message.id
|
|
}.isEmpty)
|
|
|
|
conversation.handle(.messageDelta(messageID: continuation.id, text: "Answer"))
|
|
#expect(conversation.workGroups.first?.activeReasoningBlockID == nil)
|
|
}
|
|
|
|
@Test
|
|
func finishedReasoningDisappearsWhileToolsMergeAcrossProviderTurns() async throws {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
conversation.appendUserTurn("inspect both")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.reasoningDelta("Choose both checks."),
|
|
.toolCallDelta(id: "call-1", name: "lookup", arguments: "{}"),
|
|
.toolCallDelta(id: "call-2", name: "lookup", arguments: "{}"),
|
|
.finished(.toolCalls),
|
|
],
|
|
[
|
|
.reasoningDelta("Verify the combined result."),
|
|
.toolCallDelta(id: "call-3", name: "lookup", arguments: "{}"),
|
|
.finished(.toolCalls),
|
|
],
|
|
[
|
|
.reasoningDelta("Compare all results."),
|
|
.textDelta("Both checks passed."),
|
|
.finished(.completed),
|
|
],
|
|
]),
|
|
tools: [
|
|
AnyAgentTool(
|
|
descriptor: AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup",
|
|
inputSchema: .object([
|
|
"type": .string("object"), "properties": .object([:]),
|
|
"required": .array([]), "additionalProperties": .bool(false),
|
|
]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
) { invocation, _ in
|
|
AgentToolResult(callID: invocation.call.id, content: "ok")
|
|
}
|
|
],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "inspect both",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
#expect(conversation.workGroups.count == 1)
|
|
let kinds = group.items.map { item in
|
|
switch item {
|
|
case .reasoning(let block): return "reasoning:\(block.text)"
|
|
case .message(let message): return "message:\(message.text)"
|
|
case .tool(let card): return "tool:\(card.invocation.call.id)"
|
|
case .tools(let batch):
|
|
return "tools:\(batch.cards.map(\.invocation.call.id).joined(separator: ","))"
|
|
}
|
|
}
|
|
#expect(kinds == ["tools:call-1,call-2,call-3"])
|
|
#expect(
|
|
conversation.timeline.compactMap { item in
|
|
if case .work = item { return "work" }
|
|
return nil
|
|
} == ["work"])
|
|
}
|
|
|
|
@Test
|
|
func providerTurnsWithoutReasoningShareOneRunScopedToolBatch() async throws {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
conversation.appendUserTurn("inspect four")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.toolCallDelta(id: "call-1", name: "lookup", arguments: "{}"),
|
|
.toolCallDelta(id: "call-2", name: "lookup", arguments: "{}"),
|
|
.finished(.toolCalls),
|
|
],
|
|
[
|
|
.toolCallDelta(id: "call-3", name: "lookup", arguments: "{}"),
|
|
.toolCallDelta(id: "call-4", name: "lookup", arguments: "{}"),
|
|
.finished(.toolCalls),
|
|
],
|
|
[.textDelta("Done."), .finished(.completed)],
|
|
]),
|
|
tools: [
|
|
AnyAgentTool(
|
|
descriptor: AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup",
|
|
inputSchema: .object([
|
|
"type": .string("object"), "properties": .object([:]),
|
|
"required": .array([]), "additionalProperties": .bool(false),
|
|
]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
) { invocation, _ in
|
|
AgentToolResult(callID: invocation.call.id, content: "ok")
|
|
}
|
|
],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "inspect four",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
let batches: [[String]] = group.items.map { item -> [String] in
|
|
switch item {
|
|
case .tools(let batch): batch.cards.map(\.invocation.call.id)
|
|
case .tool(let card): [card.invocation.call.id]
|
|
case .reasoning, .message: []
|
|
}
|
|
}.filter { !$0.isEmpty }
|
|
#expect(batches == [["call-1", "call-2", "call-3", "call-4"]])
|
|
}
|
|
|
|
@Test
|
|
func terminalSuggestionStaysIndependentAndBreaksAdjacentToolBatches() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("prepare")
|
|
let runID = UUID()
|
|
let sourceMessageID = UUID()
|
|
let startedAt = Date()
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: startedAt
|
|
)))
|
|
let calls = [
|
|
"lookup", "lookup", "terminal_suggest", "lookup", "lookup",
|
|
].enumerated().map { offset, name in
|
|
AgentToolCall(id: "call-\(offset)", name: name, arguments: .object([:]))
|
|
}
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
id: sourceMessageID,
|
|
role: .assistant,
|
|
toolCalls: calls,
|
|
createdAt: startedAt
|
|
)))
|
|
let lookup = AgentToolDescriptor(
|
|
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)
|
|
let suggestion = AnyAgentTool(TerminalSuggestTool()).descriptor
|
|
#expect(suggestion.presentation?.groupsWithAdjacentTools == false)
|
|
for (offset, name) in [
|
|
"lookup", "lookup", "terminal_suggest", "lookup", "lookup",
|
|
].enumerated() {
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(id: "call-\(offset)", name: name, arguments: .object([:])),
|
|
sourceMessageID: sourceMessageID,
|
|
createdAt: startedAt.addingTimeInterval(Double(offset + 1))
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation, name == "terminal_suggest" ? suggestion : lookup
|
|
))
|
|
}
|
|
|
|
let group = try #require(conversation.workGroups.first)
|
|
let items = group.items
|
|
let groups = items.map { item -> [String] in
|
|
switch item {
|
|
case .tools(let batch): batch.cards.map(\.descriptor.name)
|
|
case .tool(let card): [card.descriptor.name]
|
|
case .reasoning, .message: []
|
|
}
|
|
}.filter { !$0.isEmpty }
|
|
#expect(
|
|
groups == [
|
|
["lookup", "lookup", "lookup", "lookup"], ["terminal_suggest"],
|
|
])
|
|
}
|
|
|
|
@Test
|
|
func completedLegacyTerminalSuggestionIsPromotedWithoutItsExplanation() throws {
|
|
let conversation = ConversationModel()
|
|
let runID = UUID()
|
|
let createdAt = Date(timeIntervalSince1970: 2_000)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID,
|
|
state: .running,
|
|
startedAt: createdAt
|
|
)))
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(
|
|
id: "suggest-command",
|
|
name: "terminal_suggest",
|
|
arguments: .object([
|
|
"command": .string("pwd"),
|
|
"explanation": .string("Show the current directory."),
|
|
])
|
|
),
|
|
createdAt: createdAt
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AnyAgentTool(TerminalSuggestTool()).descriptor
|
|
))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
invocation,
|
|
AgentToolResult(callID: invocation.call.id, content: "{}")
|
|
))
|
|
|
|
#expect(conversation.workGroups.isEmpty)
|
|
let item = try #require(conversation.timeline.first)
|
|
guard case .command(let command) = item else {
|
|
Issue.record("Expected a promoted command card, got \(item)")
|
|
return
|
|
}
|
|
#expect(command.id == invocation.id)
|
|
#expect(command.command == "pwd")
|
|
#expect(command.createdAt == createdAt)
|
|
}
|
|
|
|
@Test
|
|
func multipleWorkGroupsSharingAnAnchorKeepChronologicalOrder() {
|
|
let start = Date(timeIntervalSince1970: 100)
|
|
let answer = ChatMessage(role: .assistant, text: "Answer", createdAt: start)
|
|
let next = ChatMessage(role: .user, text: "Next", createdAt: start.addingTimeInterval(10))
|
|
let first = AgentRunSummary(
|
|
id: UUID(), state: .cancelled, startedAt: start,
|
|
finishedAt: start.addingTimeInterval(1))
|
|
let second = AgentRunSummary(
|
|
id: UUID(), state: .cancelled, startedAt: start.addingTimeInterval(2),
|
|
finishedAt: start.addingTimeInterval(3))
|
|
let conversation = ConversationModel(
|
|
messages: [answer, next], messageRunIDs: [answer.id: first.id], runs: [second, first])
|
|
#expect(conversation.workGroups.map(\.id) == [first.id, second.id])
|
|
#expect(conversation.workGroups.map(\.anchorMessageID) == [next.id, next.id])
|
|
#expect(conversation.workGroups.map(\.appearsAfterAnswer) == [true, false])
|
|
#expect(conversation.timeline.map(\.id) == [answer.id, first.id, second.id, next.id])
|
|
}
|
|
|
|
@Test
|
|
func restoredReasoningStaysPersistedButOutOfWorked() throws {
|
|
let conversationID = UUID()
|
|
let runID = UUID()
|
|
let createdAt = Date(timeIntervalSince1970: 2_000)
|
|
let reasoning = AgentReasoningBlock(
|
|
text: "Restored provider summary.", createdAt: createdAt
|
|
)
|
|
let assistant = AgentTranscriptMessage(
|
|
role: .assistant, text: "Restored answer", reasoning: [reasoning],
|
|
createdAt: createdAt
|
|
)
|
|
let store = AssistantConversationStore()
|
|
store.restore([
|
|
StoredAgentConversation(
|
|
id: conversationID, messages: [assistant],
|
|
messageRunIDs: [assistant.id: runID],
|
|
runs: [
|
|
AgentRunSummary(
|
|
id: runID, state: .completed, startedAt: createdAt,
|
|
finishedAt: createdAt.addingTimeInterval(1)
|
|
)
|
|
],
|
|
lastRunState: .completed
|
|
)
|
|
])
|
|
|
|
let restored = try #require(store.conversation(id: conversationID))
|
|
#expect(restored.messages.first?.reasoning == [reasoning])
|
|
#expect(restored.workGroups.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func stoppedDirectAnswerShowsItsStoppedDurationAfterThePartialAnswer() {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("Explain")
|
|
let runID = UUID()
|
|
let start = Date()
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: start
|
|
)))
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
role: .assistant, text: "Partial answer",
|
|
createdAt: start.addingTimeInterval(1)
|
|
)))
|
|
conversation.handle(
|
|
.runFinished(
|
|
AgentRunSummary(
|
|
id: runID, state: .cancelled, startedAt: start,
|
|
finishedAt: start.addingTimeInterval(62)
|
|
)))
|
|
|
|
let order = conversation.timeline.map { item in
|
|
switch item {
|
|
case .message(let message): return message.role == .user ? "user" : "assistant"
|
|
case .work: return "work"
|
|
default: return "other"
|
|
}
|
|
}
|
|
#expect(order == ["user", "assistant", "work"])
|
|
#expect(conversation.workGroups.first?.items.isEmpty == true)
|
|
#expect(conversation.workGroups.first?.appearsAfterAnswer == true)
|
|
let run = conversation.workGroups.first?.run
|
|
#expect(
|
|
run.map {
|
|
AgentWorkPresentation.title(
|
|
for: $0.state, from: $0.startedAt, to: $0.finishedAt ?? start,
|
|
locale: Locale(identifier: "en")
|
|
)
|
|
} == "You stopped after 1m 2s")
|
|
}
|
|
|
|
@Test
|
|
func providerSearchReportedAfterTextAppearsAfterTheAnswerAndExpanded() {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("search")
|
|
let runID = UUID()
|
|
let startedAt = Date()
|
|
let answer = AgentTranscriptMessage(
|
|
role: .assistant, text: "The answer", createdAt: startedAt.addingTimeInterval(1)
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: startedAt
|
|
)))
|
|
conversation.handle(.messageStarted(answer))
|
|
conversation.handle(.messageFinished(answer))
|
|
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID,
|
|
call: AgentToolCall(
|
|
id: "provider-search", name: "web_search",
|
|
arguments: .object(["query": .string("search")])
|
|
),
|
|
sourceMessageID: answer.id,
|
|
createdAt: startedAt.addingTimeInterval(2)
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "web_search", summary: "Provider search",
|
|
inputSchema: .object([:]), target: .network, approvalPolicy: .approve
|
|
)))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
invocation,
|
|
AgentToolResult(
|
|
callID: invocation.call.id, content: "[]",
|
|
metadata: [AgentToolResult.providerNativeKey: .bool(true)]
|
|
)))
|
|
conversation.handle(
|
|
.runFinished(
|
|
AgentRunSummary(
|
|
id: runID, state: .completed, startedAt: startedAt,
|
|
finishedAt: startedAt.addingTimeInterval(3)
|
|
)))
|
|
|
|
let order = conversation.timeline.map { item in
|
|
switch item {
|
|
case .message(let message): return message.role == .user ? "user" : "assistant"
|
|
case .work: return "work"
|
|
default: return "other"
|
|
}
|
|
}
|
|
#expect(order == ["user", "assistant", "work"])
|
|
#expect(conversation.workGroups.first?.items.count == 1)
|
|
#expect(conversation.workGroups.first?.appearsAfterAnswer == true)
|
|
#expect(
|
|
AgentWorkPresentation.isExpandedByDefault(
|
|
for: .completed, appearsAfterAnswer: true
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func workDurationUsesAtMostTwoUnitsAndSuccessfulRunsCollapseByDefault() {
|
|
let start = Date(timeIntervalSince1970: 1_000)
|
|
let en = Locale(identifier: "en")
|
|
let zh = Locale(identifier: "zh-Hans")
|
|
#expect(
|
|
AgentWorkPresentation.duration(
|
|
from: start, to: start.addingTimeInterval(42), locale: en
|
|
) == "42s")
|
|
#expect(
|
|
AgentWorkPresentation.duration(
|
|
from: start, to: start.addingTimeInterval(68), locale: en
|
|
) == "1m 8s")
|
|
#expect(
|
|
AgentWorkPresentation.duration(
|
|
from: start, to: start.addingTimeInterval(4_320), locale: en
|
|
) == "1h 12m")
|
|
#expect(!AgentWorkPresentation.isExpandedByDefault(for: .completed))
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: .running))
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: .waitingForUser))
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: .failed))
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: .cancelled))
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: .interrupted))
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: .running, isCompact: true))
|
|
#expect(
|
|
AgentWorkPresentation.isExpandedByDefault(
|
|
for: .waitingForUser, appearsAfterAnswer: true, isCompact: true
|
|
))
|
|
#expect(
|
|
AgentWorkPresentation.title(
|
|
for: .running, from: start, to: start.addingTimeInterval(68), locale: en
|
|
) == "Working for 1m 8s")
|
|
#expect(
|
|
AgentWorkPresentation.title(
|
|
for: .completed, from: start, to: start.addingTimeInterval(68), locale: en
|
|
) == "Worked for 1m 8s")
|
|
#expect(
|
|
AgentWorkPresentation.title(
|
|
for: .cancelled, from: start, to: start.addingTimeInterval(62), locale: en
|
|
) == "You stopped after 1m 2s")
|
|
#expect(
|
|
AgentWorkPresentation.duration(
|
|
from: start, to: start.addingTimeInterval(42), locale: zh
|
|
) == "42秒")
|
|
#expect(
|
|
AgentWorkPresentation.duration(
|
|
from: start, to: start.addingTimeInterval(68), locale: zh
|
|
) == "1分钟8秒")
|
|
#expect(
|
|
AgentWorkPresentation.duration(
|
|
from: start, to: start.addingTimeInterval(4_320), locale: zh
|
|
) == "1小时12分钟")
|
|
#expect(
|
|
AgentWorkPresentation.title(
|
|
for: .running, from: start, to: start.addingTimeInterval(68), locale: zh
|
|
) == "已工作1分钟8秒")
|
|
// Chinese uses one phrase where English distinguishes "Working for"
|
|
// from "Worked for". The running and finished rows are already told
|
|
// apart by the spinner and by whether the disclosure is folded, so the
|
|
// tense the English carries is not load-bearing here.
|
|
#expect(
|
|
AgentWorkPresentation.title(
|
|
for: .completed, from: start, to: start.addingTimeInterval(68), locale: zh
|
|
) == "已工作1分钟8秒")
|
|
#expect(
|
|
AgentWorkPresentation.title(
|
|
for: .cancelled, from: start, to: start.addingTimeInterval(62), locale: zh
|
|
) == "你在1分钟2秒后停止了")
|
|
}
|
|
|
|
/// A turn that only called tools has nothing to say, and an empty bubble
|
|
/// reads as the model having said nothing at all.
|
|
@Test
|
|
func aSilentToolOnlyTurnDoesNotLeaveAnEmptyBubble() {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("go")
|
|
let started = AgentTranscriptMessage(role: .assistant)
|
|
let id = started.id
|
|
conversation.handle(.messageStarted(started))
|
|
#expect(conversation.timeline.count == 2, "a streaming turn still shows progress")
|
|
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
id: id, role: .assistant, text: "",
|
|
toolCalls: [AgentToolCall(id: "c", name: "lookup", arguments: .object([:]))]
|
|
)))
|
|
|
|
#expect(conversation.timeline.count == 1)
|
|
#expect(conversation.messages.count == 2, "the turn is still in the history")
|
|
}
|
|
|
|
/// The runtime held the conversation through both its approval broker and
|
|
/// its interaction handler, while the conversation held the runtime — so
|
|
/// every chat that ever ran an agent stayed alive for the whole session.
|
|
@Test
|
|
func aFinishedRunDoesNotKeepItsConversationAlive() async {
|
|
weak var leaked: ConversationModel?
|
|
do {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
leaked = conversation
|
|
conversation.appendUserTurn("hello")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.textDelta("hi"), .finished(.completed),
|
|
]
|
|
]),
|
|
tools: [],
|
|
approval: AgentApprovalBroker(
|
|
reviewer: nil,
|
|
manualApproval: { [weak conversation] request in
|
|
guard let conversation else { return .deny("gone") }
|
|
return await conversation.requestApproval(request)
|
|
}
|
|
),
|
|
repository: repository,
|
|
userInteraction: WeakAgentUserInteraction(conversation)
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "hello",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
}
|
|
// The event task holds the conversation until the stream ends; give the
|
|
// runloop a turn to release it.
|
|
await wait { leaked == nil }
|
|
#expect(leaked == nil)
|
|
}
|
|
|
|
// MARK: - Attachments
|
|
|
|
/// An attachment used to be a label for a payload that travelled separately
|
|
/// — pasted into the draft — so what the chip claimed and what was uploaded
|
|
/// were two different things.
|
|
@Test
|
|
func anAttachmentIsSentWithTheTurnItWasAttachedTo() {
|
|
let conversation = ConversationModel()
|
|
conversation.attach(
|
|
ContextChip(
|
|
kind: .output, label: "Connection error", content: "ssh: connect refused"
|
|
))
|
|
|
|
let sent = conversation.appendUserTurn("What should I check?")
|
|
|
|
#expect(sent.hasPrefix("What should I check?"))
|
|
#expect(sent.contains(#"<output label="Connection error">"#))
|
|
#expect(sent.contains("ssh: connect refused"))
|
|
// The model keeps the payload, while the bubble renders only what the
|
|
// user typed so a large extraction never becomes a Markdown tree.
|
|
#expect(conversation.messages.last?.text == "What should I check?")
|
|
#expect(conversation.transcript.last?.text == sent)
|
|
}
|
|
|
|
@Test
|
|
func pastedTextStaysOutOfTheVisibleBubble() {
|
|
let conversation = ConversationModel()
|
|
let pasted = String(repeating: "large pasted context\n", count: 2_000)
|
|
conversation.attach(ContextChip(kind: .pastedText, label: "Pasted text", content: pasted))
|
|
|
|
let sent = conversation.appendUserTurn("Summarize it")
|
|
|
|
#expect(sent.contains(pasted))
|
|
#expect(conversation.transcript.last?.text.contains(pasted) == true)
|
|
#expect(conversation.messages.last?.text == "Summarize it")
|
|
#expect(conversation.messages.last?.contextChips.map(\.label) == ["Pasted text"])
|
|
}
|
|
|
|
@Test
|
|
func restoredAttachmentPayloadStaysOutOfTheVisibleBubble() {
|
|
let payload = String(repeating: "PDF extraction\n", count: 10_000)
|
|
let modelText = "Review this\n\n<file label=\"manual.pdf\">\n\(payload)\n</file>"
|
|
let message = AgentTranscriptMessage(role: .user, text: modelText)
|
|
let store = AssistantConversationStore()
|
|
|
|
store.restore([
|
|
StoredAgentConversation(
|
|
id: UUID(), messages: [message], lastRunState: .completed
|
|
)
|
|
])
|
|
|
|
#expect(store.conversations.first?.messages.first?.text == "Review this")
|
|
#expect(
|
|
store.conversations.first?.messages.first?.contextChips.map(\.label) == [
|
|
"manual.pdf"
|
|
])
|
|
#expect(store.conversations.first?.transcript.first?.text == modelText)
|
|
}
|
|
|
|
/// Consumed, not kept: the payload is in the transcript from here on, and
|
|
/// re-uploading it every turn is the bill the user's own key pays.
|
|
@Test
|
|
func anAttachmentIsConsumedByTheTurnAndNotResent() {
|
|
let conversation = ConversationModel()
|
|
conversation.attach(ContextChip(kind: .file, label: "nginx.conf", content: "worker;"))
|
|
|
|
conversation.appendUserTurn("first")
|
|
#expect(conversation.composer.attachments.isEmpty)
|
|
|
|
let second = conversation.appendUserTurn("second")
|
|
#expect(second == "second")
|
|
}
|
|
|
|
@Test
|
|
func detachingRemovesTheChipAndItsPayload() {
|
|
let conversation = ConversationModel()
|
|
let chip = ContextChip(kind: .selection, label: "Selection", content: "df -h")
|
|
conversation.attach(chip)
|
|
conversation.attach(chip)
|
|
|
|
#expect(conversation.composer.attachments.count == 1, "the same attachment twice is once")
|
|
|
|
conversation.detach(chip.id)
|
|
|
|
#expect(conversation.composer.attachments.isEmpty)
|
|
#expect(conversation.appendUserTurn("hello") == "hello")
|
|
}
|
|
|
|
// MARK: - Titling
|
|
|
|
@Test
|
|
func aFinishedRunNotifiesItsOwnerExactlyOnce() async {
|
|
let conversation = ConversationModel()
|
|
let repository = InMemoryAgentRunRepository()
|
|
var finishes = 0
|
|
conversation.runDidFinish = { finishes += 1 }
|
|
conversation.appendUserTurn("hello")
|
|
conversation.start(
|
|
runtime: AgentRuntime(
|
|
model: ImmediateConversationAgentModel(turns: [
|
|
[
|
|
.textDelta("hi"), .finished(.completed),
|
|
]
|
|
]),
|
|
tools: [],
|
|
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }),
|
|
repository: repository
|
|
),
|
|
request: AgentRunRequest(
|
|
conversationID: conversation.id, prompt: "hello",
|
|
permissionMode: .askForApproval
|
|
),
|
|
repository: repository
|
|
)
|
|
await waitForTerminal(conversation)
|
|
await wait { finishes > 0 }
|
|
|
|
#expect(finishes == 1)
|
|
}
|
|
|
|
@Test
|
|
func thereIsNothingToSummarizeUntilTheModelHasAnswered() {
|
|
let conversation = ConversationModel()
|
|
#expect(AppModel.titleContext(for: conversation) == nil)
|
|
|
|
conversation.appendUserTurn("Root is at 99%")
|
|
#expect(AppModel.titleContext(for: conversation) == nil)
|
|
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(role: .assistant, text: "Start with du -sh /var/*.")
|
|
))
|
|
|
|
let context = AppModel.titleContext(for: conversation)
|
|
#expect(context?.firstUserMessage == "Root is at 99%")
|
|
#expect(context?.firstAssistantMessage == "Start with du -sh /var/*.")
|
|
}
|
|
|
|
@Test
|
|
func aConversationThatAlreadyHasATitleIsNotSummarizedAgain() {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("Root is at 99%")
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(role: .assistant, text: "Start with du.")
|
|
))
|
|
#expect(AppModel.titleContext(for: conversation) != nil)
|
|
|
|
conversation.title = "Root volume filling up"
|
|
|
|
#expect(AppModel.titleContext(for: conversation) == nil)
|
|
#expect(conversation.derivedTitle == "Root volume filling up")
|
|
}
|
|
|
|
private func beginStreamingConversation() -> (
|
|
conversation: ConversationModel, runID: UUID, assistant: AgentTranscriptMessage
|
|
) {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("Explain")
|
|
let runID = UUID()
|
|
// Keep both event stamps before the stop that follows in each test,
|
|
// matching the runtime's monotonic event order.
|
|
let startedAt = Date().addingTimeInterval(-2)
|
|
let assistant = AgentTranscriptMessage(
|
|
role: .assistant, createdAt: startedAt.addingTimeInterval(1)
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: startedAt
|
|
)))
|
|
conversation.handle(.runState(.running))
|
|
conversation.handle(.messageStarted(assistant))
|
|
return (conversation, runID, assistant)
|
|
}
|
|
|
|
private func timelineKind(_ item: TimelineItem) -> String {
|
|
switch item {
|
|
case .message(let message): return message.role == .user ? "user" : "assistant"
|
|
case .work: return "work"
|
|
default: return "other"
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func derivedTitleFallsBackToTheOpeningLineUntilASummaryArrives() {
|
|
let conversation = ConversationModel()
|
|
#expect(conversation.derivedTitle == "New Chat")
|
|
|
|
conversation.appendUserTurn("Root is at 99%\nand climbing")
|
|
#expect(conversation.derivedTitle == "Root is at 99%")
|
|
|
|
conversation.title = "Disk pressure on root"
|
|
#expect(conversation.derivedTitle == "Disk pressure on root")
|
|
}
|
|
|
|
@Test
|
|
func editingAUserTurnTruncatesTheVisibleAndModelHistoryAndKeepsAttachments() throws {
|
|
let image = AgentImageAttachment(
|
|
label: "screen.png", mimeType: "image/png", data: Data([1, 2, 3]),
|
|
pixelWidth: 10, pixelHeight: 10
|
|
)
|
|
let conversation = ConversationModel()
|
|
conversation.attach(ContextChip(kind: .image, label: image.label, image: image))
|
|
let opening = conversation.appendUserTurnContent("Original")
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
role: .assistant, text: "First answer", createdAt: .now.addingTimeInterval(1)
|
|
)))
|
|
_ = conversation.appendUserTurnContent("Follow-up")
|
|
conversation.handle(
|
|
.messageFinished(
|
|
AgentTranscriptMessage(
|
|
role: .assistant, text: "Second answer", createdAt: .now.addingTimeInterval(2)
|
|
)))
|
|
|
|
let replacement = try #require(
|
|
conversation.editUserMessage(opening.id, authoredText: "Edited")
|
|
)
|
|
|
|
#expect(conversation.messages.map(\.text) == ["Edited"])
|
|
#expect(conversation.transcript.count == 1)
|
|
#expect(conversation.transcript.first?.authoredText == "Edited")
|
|
#expect(replacement.images == [image])
|
|
}
|
|
|
|
@Test
|
|
func onlyAssistantMessagesInReplayableHistoryCanBeForked() throws {
|
|
let conversation = ConversationModel()
|
|
let user = conversation.appendUserTurnContent("Question")
|
|
let assistant = AgentTranscriptMessage(role: .assistant, text: "Answer")
|
|
conversation.handle(.messageFinished(assistant))
|
|
|
|
#expect(!conversation.canForkAssistantMessage(user.id))
|
|
#expect(conversation.canForkAssistantMessage(assistant.id))
|
|
let fork = try #require(conversation.forked(throughAssistantMessage: assistant.id))
|
|
#expect(fork.id != conversation.id)
|
|
#expect(fork.messages.map(\.text) == ["Question", "Answer"])
|
|
#expect(Set(fork.transcript.map(\.id)).isDisjoint(with: conversation.transcript.map(\.id)))
|
|
#expect(fork.customTitle == "Question (1)")
|
|
}
|
|
|
|
@Test
|
|
func forkRemapsWorkRunAndSourceMessageIDs() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("Question")
|
|
let runID = UUID()
|
|
let startedAt = Date()
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: startedAt
|
|
)))
|
|
let process = AgentTranscriptMessage(
|
|
role: .assistant, text: "Checking",
|
|
toolCalls: [AgentToolCall(id: "call", name: "lookup", arguments: .object([:]))],
|
|
createdAt: startedAt.addingTimeInterval(1)
|
|
)
|
|
conversation.handle(.messageFinished(process))
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID, call: process.toolCalls[0], sourceMessageID: process.id,
|
|
createdAt: startedAt.addingTimeInterval(2)
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "lookup", summary: "Lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
invocation, AgentToolResult(callID: "call", content: "result")
|
|
))
|
|
let final = AgentTranscriptMessage(
|
|
role: .assistant, text: "Answer", createdAt: startedAt.addingTimeInterval(3)
|
|
)
|
|
conversation.handle(.messageFinished(final))
|
|
conversation.handle(
|
|
.runFinished(
|
|
AgentRunSummary(
|
|
id: runID, state: .completed, startedAt: startedAt,
|
|
finishedAt: startedAt.addingTimeInterval(4)
|
|
)))
|
|
|
|
let fork = try #require(conversation.forked(throughAssistantMessage: final.id))
|
|
let forkRunIDs = Set(fork.runSummaries.keys)
|
|
let clonedProcess = try #require(
|
|
fork.transcript.first {
|
|
$0.role == .assistant && !$0.toolCalls.isEmpty
|
|
})
|
|
let clonedCard = try #require(fork.toolCards.first)
|
|
#expect(!forkRunIDs.contains(runID))
|
|
#expect(forkRunIDs.contains(clonedCard.invocation.runID))
|
|
#expect(clonedCard.invocation.sourceMessageID == clonedProcess.id)
|
|
#expect(Set(fork.messageRunIDs.values).isSubset(of: forkRunIDs))
|
|
}
|
|
|
|
@Test
|
|
func editingAUserTurnRemovesDownstreamToolActivity() throws {
|
|
let conversation = ConversationModel()
|
|
let user = conversation.appendUserTurnContent("Question")
|
|
let runID = UUID()
|
|
let process = AgentTranscriptMessage(
|
|
role: .assistant, text: "Checking",
|
|
toolCalls: [AgentToolCall(id: "call", name: "lookup", arguments: .object([:]))]
|
|
)
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(
|
|
id: runID, state: .running, startedAt: .now
|
|
)))
|
|
conversation.handle(.messageFinished(process))
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID, call: process.toolCalls[0], sourceMessageID: process.id
|
|
)
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: "lookup", summary: "Lookup", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve
|
|
)))
|
|
conversation.handle(
|
|
.toolFinished(invocation, AgentToolResult(callID: invocation.call.id, content: "Result")))
|
|
conversation.handle(
|
|
.runFinished(
|
|
AgentRunSummary(id: runID, state: .completed, startedAt: .now, finishedAt: .now)))
|
|
#expect(conversation.workGroups.count == 1)
|
|
|
|
#expect(conversation.canEditUserMessage(user.id))
|
|
let replacement = try #require(conversation.editUserMessage(user.id, authoredText: "Edited"))
|
|
|
|
#expect(conversation.workGroups.isEmpty)
|
|
#expect(conversation.toolCards.isEmpty)
|
|
#expect(conversation.runSummaries.count == 1)
|
|
#expect(conversation.runSummaries[runID] == nil)
|
|
#expect(conversation.transcript.map(\.id) == [replacement.id])
|
|
#expect(conversation.messages.map(\.text) == ["Edited"])
|
|
}
|
|
|
|
@Test(arguments: [
|
|
AgentToolCard.State.proposed, .waitingForApproval, .waitingForUser, .reviewing,
|
|
.running, .completed, .denied, .failed, .interrupted,
|
|
])
|
|
func editingRemovesToolsInEveryLifecycleState(_ state: AgentToolCard.State) throws {
|
|
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))
|
|
let replacement = try #require(conversation.editUserMessage(opening.id, authoredText: "Edited"))
|
|
#expect(conversation.toolCards.isEmpty)
|
|
#expect(conversation.transcript.map(\.id) == [replacement.id])
|
|
}
|
|
|
|
@Test
|
|
func editingSteeringKeepsCompactedSameRunPrefixAndLegacyToolOwnership() throws {
|
|
let instant = Date(timeIntervalSinceReferenceDate: 100)
|
|
let runID = UUID()
|
|
let opening = AgentTranscriptMessage(
|
|
role: .user, text: "Opening", authoredText: "Opening", createdAt: instant)
|
|
let call = AgentToolCall(id: "prefix-call", name: "inspect", arguments: .object([:]))
|
|
let firstReply = AgentTranscriptMessage(
|
|
role: .assistant, text: "First reply", toolCalls: [call], createdAt: instant)
|
|
let steering = AgentTranscriptMessage(
|
|
role: .user, text: "Steer", authoredText: "Steer", createdAt: instant)
|
|
let secondReply = AgentTranscriptMessage(
|
|
role: .assistant, text: "Second reply", createdAt: instant)
|
|
let keptCompaction = AgentCompactionRecord(
|
|
compactedThroughMessageID: firstReply.id, sequence: 0, summary: "Earlier context",
|
|
summarizedTokens: 10, keptTokens: 2, displayAnchor: instant)
|
|
let removedCompaction = AgentCompactionRecord(
|
|
compactedThroughMessageID: secondReply.id, sequence: 1, summary: "Later context",
|
|
summarizedTokens: 12, keptTokens: 2, displayAnchor: instant)
|
|
let conversation = ConversationModel(
|
|
messages: [
|
|
ChatMessage(id: opening.id, role: .user, text: opening.text, createdAt: instant),
|
|
ChatMessage(id: firstReply.id, role: .assistant, text: firstReply.text, createdAt: instant),
|
|
ChatMessage(id: steering.id, role: .user, text: steering.text, createdAt: instant),
|
|
ChatMessage(id: secondReply.id, role: .assistant, text: secondReply.text, createdAt: instant),
|
|
],
|
|
transcript: [steering, secondReply], runState: .failed,
|
|
messageRunIDs: [
|
|
opening.id: runID, firstReply.id: runID, steering.id: runID, secondReply.id: runID,
|
|
],
|
|
runs: [
|
|
AgentRunSummary(
|
|
id: runID, state: .failed, startedAt: instant,
|
|
finishedAt: instant, failure: "old failure")
|
|
],
|
|
compactionRecords: [keptCompaction, removedCompaction])
|
|
conversation.title = "Keep this title"
|
|
let invocation = AgentToolInvocation(
|
|
runID: runID, call: call, sourceMessageID: firstReply.id,
|
|
createdAt: instant.addingTimeInterval(1))
|
|
conversation.handle(
|
|
.toolProposed(
|
|
invocation,
|
|
AgentToolDescriptor(
|
|
name: call.name, summary: "Inspect", inputSchema: .object([:]),
|
|
target: .local, approvalPolicy: .approve)))
|
|
conversation.handle(
|
|
.planPresented(
|
|
AgentPlan(
|
|
title: "Same-time plan", body: "Must be removed", scratchPath: "/tmp/plan.md",
|
|
createdAt: instant)))
|
|
|
|
let replacement = try #require(
|
|
conversation.editUserMessage(steering.id, authoredText: "Replacement"))
|
|
|
|
#expect(conversation.messages.map(\.text) == ["Opening", "First reply", "Replacement"])
|
|
#expect(conversation.transcript.map(\.text) == ["Replacement"])
|
|
#expect(conversation.toolCards.map(\.id) == [invocation.id])
|
|
#expect(conversation.compactionRecords == [keptCompaction])
|
|
#expect(conversation.compactions.map(\.id) == [keptCompaction.id])
|
|
#expect(conversation.plans.isEmpty)
|
|
#expect(conversation.title == "Keep this title")
|
|
let prefix = try #require(conversation.runSummaries[runID])
|
|
#expect(prefix.state == .completed)
|
|
#expect(prefix.finishedAt == instant)
|
|
#expect(prefix.failure == nil)
|
|
#expect(conversation.messageRunIDs[opening.id] == runID)
|
|
#expect(conversation.messageRunIDs[firstReply.id] == runID)
|
|
#expect(conversation.messageRunIDs[replacement.id] == replacement.id)
|
|
|
|
let stagingStartedAt = try #require(conversation.runSummaries[replacement.id]).startedAt
|
|
let realRunID = UUID()
|
|
conversation.handle(
|
|
.runStarted(
|
|
AgentRunSummary(id: realRunID, state: .running, startedAt: instant)))
|
|
#expect(conversation.messageRunIDs[replacement.id] == realRunID)
|
|
#expect(conversation.runSummaries[realRunID]?.startedAt == stagingStartedAt)
|
|
conversation.handle(
|
|
.runFinished(
|
|
AgentRunSummary(
|
|
id: realRunID, state: .completed, startedAt: instant,
|
|
finishedAt: instant)))
|
|
#expect(conversation.runSummaries[realRunID]?.startedAt == stagingStartedAt)
|
|
#expect(conversation.runSummaries[realRunID]?.finishedAt == stagingStartedAt)
|
|
}
|
|
|
|
private func waitForTerminal(_ conversation: ConversationModel) async {
|
|
// Multi-turn scripted runs also exercise the bounded presentation
|
|
// drain, so their terminal event can legitimately arrive after the
|
|
// old one-second polling window.
|
|
for _ in 0..<1_000 where !conversation.runState.isTerminal {
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
}
|
|
|
|
private func wait(for condition: () async -> Bool) async {
|
|
for _ in 0..<400 {
|
|
if await condition() { return }
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
private final class ComposerScrollerTestState {
|
|
var text = ""
|
|
}
|
|
|
|
private struct ComposerScrollerTestView: View {
|
|
@Bindable var state: ComposerScrollerTestState
|
|
|
|
var body: some View {
|
|
TextEditor(text: $state.text)
|
|
.autohidingScrollers()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func requireComposerScroller(
|
|
in root: NSView,
|
|
condition: (NSScrollView) -> Bool = { _ in true }
|
|
) async throws -> NSScrollView {
|
|
for _ in 0..<100 {
|
|
root.layoutSubtreeIfNeeded()
|
|
if let scrollView = composerScrollViews(in: root).first(where: condition),
|
|
scrollView.autohidesScrollers
|
|
{
|
|
return scrollView
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
Issue.record("The configured composer scroll view did not reach the expected state")
|
|
throw ComposerScrollerTestError.expectedStateNotReached
|
|
}
|
|
|
|
private func composerScrollViews(in root: NSView) -> [NSScrollView] {
|
|
var result: [NSScrollView] = []
|
|
func visit(_ view: NSView) {
|
|
if let scrollView = view as? NSScrollView,
|
|
let textView = scrollView.documentView as? NSTextView,
|
|
textView.isEditable
|
|
{
|
|
result.append(scrollView)
|
|
}
|
|
view.subviews.forEach(visit)
|
|
}
|
|
visit(root)
|
|
return result
|
|
}
|
|
|
|
private enum ComposerScrollerTestError: Error {
|
|
case expectedStateNotReached
|
|
}
|