Files
Termind/FrameworkTests/AssistantTranscriptPresentationTests.swift
T
Kaede Akino 7e78bc00c4 feat(agent): unify output truncation and scratch capture
Integrate AgentKit 2.0.1 with shared scratch storage, whole-line tool policies, generated capture paths, and external MCP projection bypass. Update ssh-exec instructions and document the complete tool policy inventory.

Keep compact expansion controls left aligned and display the AgentKit truncation marker above them on both platforms. Cover narrow and wide native layouts, both button states, and marker ordering.
2026-10-17 22:51:26 +08:00

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.utf8.count <= AgentToolPresentation.displayByteLimit)
#expect(preview.displayed.split(separator: "\n", omittingEmptySubsequences: false).count <= 120)
}
@Test func reasoningTitlesKeepEmphasisLinksAndImageAltText() {
let title = TermindMarkdownParser.inlineTitle(
"## **Inspect**\n\n`nginx` [logs](https://example.com) ![alt](https://example.com/p.png)")
#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 localLink = TermindMarkdownParser.inlineTitle("[command](file:///tmp/local)")
#expect(String(localLink.characters) == "command")
#expect(localLink.runs.contains { $0.link == URL(string: "file:///tmp/local") })
}
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))
}
}