278 lines
12 KiB
Swift
278 lines
12 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindPresentation
|
|
import Testing
|
|
|
|
@MainActor
|
|
@Suite
|
|
struct AssistantTranscriptPresentationTests {
|
|
@Test func toolBatchAndFirstToolWithTheSameSourceIDHaveIndependentDisclosures() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
let batch = AssistantTranscriptDisclosureID.toolBatch(id)
|
|
let tool = AssistantTranscriptDisclosureID.tool(id)
|
|
|
|
#expect(!state.isExpanded(batch))
|
|
#expect(!state.isExpanded(tool))
|
|
|
|
state.toggleDisclosure(batch)
|
|
#expect(state.isExpanded(batch))
|
|
#expect(!state.isExpanded(tool))
|
|
|
|
state.toggleDisclosure(tool)
|
|
#expect(state.isExpanded(batch))
|
|
#expect(state.isExpanded(tool))
|
|
|
|
state.toggleDisclosure(tool)
|
|
#expect(state.isExpanded(batch))
|
|
#expect(!state.isExpanded(tool))
|
|
|
|
state.toggleDisclosure(batch)
|
|
state.toggleDisclosure(batch)
|
|
#expect(state.isExpanded(batch))
|
|
#expect(!state.isExpanded(tool))
|
|
#expect(state.layoutRevision(for: [id]) > 0)
|
|
}
|
|
|
|
@Test func aSingleToolDefaultsClosedWithoutBecomingStoredBatchState() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
|
|
#expect(!state.isExpanded(.tool(id)))
|
|
#expect(!state.isExpanded(.toolBatch(id)))
|
|
|
|
state.toggleDisclosure(.tool(id))
|
|
#expect(state.isExpanded(.tool(id)))
|
|
#expect(!state.isExpanded(.toolBatch(id)))
|
|
}
|
|
|
|
@Test func changingFromOneToolToABatchUsesBatchDefaultsAndKeepsExplicitToolChoices() {
|
|
let id = UUID()
|
|
let untouched = AssistantTranscriptPresentationState()
|
|
|
|
#expect(!untouched.isExpanded(.tool(id)))
|
|
#expect(!untouched.isExpanded(.toolBatch(id)))
|
|
|
|
let explicitlyExpanded = AssistantTranscriptPresentationState()
|
|
explicitlyExpanded.toggleDisclosure(.tool(id))
|
|
#expect(explicitlyExpanded.isExpanded(.tool(id)))
|
|
#expect(!explicitlyExpanded.isExpanded(.toolBatch(id)))
|
|
}
|
|
|
|
@Test func completionKeepsVisibleWorkAndPruningDropsOldConversationState() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
let old = work(id: id, state: .running)
|
|
let completed = work(id: id, state: .completed)
|
|
state.prepareWorkCompletionTransitions(from: [old], to: [completed], visibleIDs: [id])
|
|
#expect(state.isExpanded(.work(id)))
|
|
#expect(state.collapseWorkOutsideViewport(visibleIDs: [id]).isEmpty)
|
|
#expect(state.collapseWorkOutsideViewport(visibleIDs: []) == [id])
|
|
#expect(!state.isExpanded(.work(id)))
|
|
|
|
state.toggleDisclosure(.work(id))
|
|
state.prune(validTimelineIDs: [])
|
|
#expect(!state.readerDidToggle(.work(id)))
|
|
#expect(state.layoutRevision(for: [id]) == 0)
|
|
}
|
|
|
|
@Test func historicalAndManuallyFoldedWorkDoNotAcquirePendingCollapses() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
let old = work(id: id, state: .running)
|
|
let completed = work(id: id, state: .completed)
|
|
state.prepareWorkCompletionTransitions(from: [], to: [completed], visibleIDs: [id])
|
|
#expect(!state.isExpanded(.work(id)))
|
|
state.toggleDisclosure(.work(id), default: true)
|
|
state.prepareWorkCompletionTransitions(from: [old], to: [completed], visibleIDs: [id])
|
|
#expect(!state.isExpanded(.work(id)))
|
|
#expect(!state.hasPendingAutoCollapse(id))
|
|
let afterAnswer = work(id: id, state: .completed, afterAnswer: true)
|
|
let fresh = AssistantTranscriptPresentationState()
|
|
fresh.prepareWorkCompletionTransitions(from: [old], to: [afterAnswer], visibleIDs: [])
|
|
#expect(fresh.isExpanded(.work(id), default: true))
|
|
}
|
|
|
|
@Test func durationFloorsSecondsAndDoesNotDependOnTheRenderingClock() {
|
|
let start = Date(timeIntervalSince1970: 10_000)
|
|
let locale = Locale(identifier: "en")
|
|
for (elapsed, expected) in [(59.99, "59s"), (60.0, "1m"), (61.9, "1m 1s"), (3_600.0, "1h"), (-1.0, "0s")] {
|
|
#expect(
|
|
AgentWorkPresentation.duration(from: start, to: start.addingTimeInterval(elapsed), locale: locale)
|
|
== expected)
|
|
}
|
|
#expect(!AgentWorkPresentation.isExpandedByDefault(for: .completed))
|
|
for state: AgentRunState in [.running, .waitingForUser, .waitingForApproval, .failed, .cancelled, .interrupted]
|
|
{
|
|
#expect(AgentWorkPresentation.isExpandedByDefault(for: state))
|
|
}
|
|
}
|
|
|
|
@Test func toolStateAndMixedBatchPreserveFailureSemantics() {
|
|
let states: [(AgentToolCard.State, AgentToolPresentation.BatchTint)] = [
|
|
(.proposed, .secondary), (.running, .secondary), (.reviewing, .warning),
|
|
(.waitingForUser, .warning), (.waitingForApproval, .warning), (.completed, .success),
|
|
(.failed, .failure), (.denied, .failure), (.interrupted, .secondary),
|
|
]
|
|
for (state, tint) in states { #expect(AgentToolPresentation.tint(for: state) == tint) }
|
|
let completed = card(state: .completed)
|
|
let failed = card(state: .failed)
|
|
#expect(AgentToolPresentation.batchTint(for: [completed, failed]) == .warning)
|
|
let title = AgentToolPresentation.batchSummary(for: [completed, failed], locale: Locale(identifier: "en"))
|
|
#expect(title.localizedCaseInsensitiveContains("failed"))
|
|
#expect(AgentToolPresentation.batchTint(for: [completed, card(state: .waitingForApproval)]) == .warning)
|
|
}
|
|
|
|
@Test func aDirectDenialBeforeTheChannelDrainsKeepsOneDeniedCard() async throws {
|
|
let value = card(state: .proposed)
|
|
let conversation = ConversationModel()
|
|
let request = AgentApprovalRequest(
|
|
invocation: value.invocation, descriptor: value.descriptor, userIntent: "Inspect")
|
|
let answer = Task { await conversation.requestApproval(request) }
|
|
defer { conversation.respondToApproval(request.id, allow: false) }
|
|
for _ in 0..<100 where conversation.pendingApprovals.isEmpty { await Task.yield() }
|
|
_ = try #require(conversation.pendingApprovals.first)
|
|
conversation.respondToApproval(request.id, allow: false)
|
|
_ = await answer.value
|
|
conversation.handle(.toolProposed(value.invocation, value.descriptor))
|
|
conversation.handle(.approvalRequested(request))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
value.invocation,
|
|
.init(
|
|
callID: value.invocation.call.id, content: "Denied", isError: true
|
|
)))
|
|
#expect(conversation.toolCards.count == 1)
|
|
#expect(conversation.toolCards.first?.state == .denied)
|
|
#expect(conversation.transcript.count == 1)
|
|
}
|
|
|
|
@Test func approvalPolicyDenialFinishesAsDeniedRatherThanFailed() {
|
|
let value = card(state: .proposed)
|
|
let conversation = ConversationModel()
|
|
conversation.handle(.toolProposed(value.invocation, value.descriptor))
|
|
conversation.handle(
|
|
.toolFinished(
|
|
value.invocation,
|
|
AgentToolResult(
|
|
callID: value.invocation.call.id, content: "Denied", isError: true,
|
|
metadata: [AgentToolResult.approvalPolicyDeniedKey: .bool(true)]
|
|
)))
|
|
|
|
#expect(conversation.toolCards.first?.state == .denied)
|
|
}
|
|
|
|
@Test func resultProjectionUsesTheInjectedPresenterAndRetainsFullCopyOutput() throws {
|
|
var value = card(state: .completed)
|
|
value.result = AgentToolResult(callID: value.invocation.call.id, content: "{\"answer\":42}")
|
|
let presenter = AgentToolDetailPresenter(id: "test.answer") { input in
|
|
[.field(.init(label: "Answer", value: input.result.objectValue?["answer"]?.encodedString ?? "missing"))]
|
|
}
|
|
let detail = try #require(AgentToolPresentation.detail(for: value, presenter: presenter))
|
|
#expect(detail.items == [.field(.init(label: "Answer", value: "42"))])
|
|
let full = (0..<250).map { "line \($0): " + String(repeating: "日", count: 80) }.joined(separator: "\n")
|
|
let preview = AgentToolPresentation.preview(full)
|
|
#expect(preview.isShortened)
|
|
#expect(preview.full == full)
|
|
#expect(preview.displayed.hasPrefix("line 0:"))
|
|
#expect(preview.displayed.contains("line 249:"))
|
|
#expect(preview.displayed.count <= AgentToolPresentation.displayCharacterLimit)
|
|
#expect(preview.displayed.split(separator: "\n", omittingEmptySubsequences: false).count <= 120)
|
|
}
|
|
|
|
@Test func reasoningTitlesKeepEmphasisAndDoNotRenderRemoteImagesOrUnsafeLinks() {
|
|
let title = TermindMarkdownParser.inlineTitle(
|
|
"## **Inspect**\n\n`nginx` [logs](https://example.com) ")
|
|
#expect(String(title.characters) == "Inspect nginx logs alt")
|
|
#expect(title.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true })
|
|
#expect(title.runs.contains { $0.inlinePresentationIntent?.contains(.code) == true })
|
|
let unsafe = TermindMarkdownParser.inlineTitle("[command](file:///tmp/local)")
|
|
#expect(String(unsafe.characters) == "command")
|
|
#expect(unsafe.runs.allSatisfy { $0.link == nil })
|
|
}
|
|
|
|
private func work(id: UUID, state: AgentRunState, afterAnswer: Bool = false) -> AssistantTranscriptEntry {
|
|
.init(
|
|
id: id,
|
|
content: .work(
|
|
.init(
|
|
run: .init(id: id, state: state, startedAt: .distantPast), items: [], orderedAt: .distantPast,
|
|
appearsAfterAnswer: afterAnswer
|
|
)))
|
|
}
|
|
|
|
private func card(state: AgentToolCard.State) -> AgentToolCard {
|
|
.init(
|
|
invocation: .init(
|
|
runID: UUID(), call: .init(id: UUID().uuidString, name: "inspect", arguments: .object([:]))),
|
|
descriptor: .init(
|
|
name: "inspect", summary: "Inspect", inputSchema: .object([:]), target: .host,
|
|
approvalPolicy: .approve, concurrency: .parallel),
|
|
state: state
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func aReaderToggleSurvivesDefaultStateTransitions() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
|
|
#expect(state.isExpanded(.work(id), default: true))
|
|
state.toggleDisclosure(.work(id), default: true)
|
|
#expect(!state.isExpanded(.work(id), default: true))
|
|
#expect(state.readerDidToggle(.work(id)))
|
|
|
|
state.followDefault(true, for: .work(id))
|
|
#expect(!state.isExpanded(.work(id), default: true))
|
|
}
|
|
|
|
@Test
|
|
func untouchedDisclosureFollowsItsRuntimeDefault() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
|
|
state.followDefault(true, for: .work(id))
|
|
#expect(state.isExpanded(.work(id)))
|
|
#expect(!state.readerDidToggle(.work(id)))
|
|
}
|
|
|
|
@Test
|
|
func deferredAutoCollapseWaitsForTheCoordinatorAndRespectsReaderOverride() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
|
|
state.deferAutoCollapse(id)
|
|
#expect(state.isExpanded(.work(id)))
|
|
#expect(state.hasPendingAutoCollapse(id))
|
|
#expect(state.completeAutoCollapse(id))
|
|
#expect(!state.isExpanded(.work(id)))
|
|
|
|
state.deferAutoCollapse(id)
|
|
state.toggleDisclosure(.work(id), default: true)
|
|
#expect(!state.hasPendingAutoCollapse(id))
|
|
#expect(!state.completeAutoCollapse(id))
|
|
}
|
|
|
|
@Test
|
|
func reasoningDeadlineIsAbsoluteAndNewMarkdownResetsIt() {
|
|
let id = UUID()
|
|
let state = AssistantTranscriptPresentationState()
|
|
let start = Date(timeIntervalSince1970: 1_000)
|
|
|
|
let first = state.reasoningTitleDeadline(
|
|
for: id, revision: "first", now: start
|
|
)
|
|
#expect(first == start.addingTimeInterval(3))
|
|
#expect(
|
|
state.reasoningTitleDeadline(
|
|
for: id, revision: "first", now: start.addingTimeInterval(2)
|
|
) == first)
|
|
#expect(
|
|
state.reasoningTitleDeadline(
|
|
for: id, revision: "second", now: start.addingTimeInterval(2)
|
|
) == start.addingTimeInterval(5))
|
|
}
|
|
|
|
}
|