147 lines
5.6 KiB
Swift
147 lines
5.6 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import Testing
|
|
|
|
/// Roughly `tokens * 4` characters, so a budget can be reasoned about in a test
|
|
/// the same way the estimator computes it. Shared with `WindowStateTests`,
|
|
/// which sizes its compaction fixture against the same budget.
|
|
func filler(tokens: Int) -> String {
|
|
String(repeating: "x", count: tokens * AgentContextEstimator.charactersPerToken)
|
|
}
|
|
|
|
@Suite
|
|
struct AgentCompactionTests {
|
|
|
|
// MARK: - Applying it
|
|
|
|
@Test
|
|
func applyingReplacesWhatIsSentAndKeepsWhatIsShown() throws {
|
|
let conversation = ConversationModel()
|
|
for index in 0..<6 {
|
|
conversation.appendUserTurn("turn \(index) \(filler(tokens: 300))")
|
|
}
|
|
let visibleBefore = conversation.messages.count
|
|
let span = try #require(conversation.compactionSpan(keepRecentTokens: 600))
|
|
|
|
conversation.applyCompaction(summary: "## Goal\nFix the disk", cut: span.cut)
|
|
|
|
// What is sent shrank.
|
|
#expect(conversation.transcript.count == 1 + (visibleBefore - span.cut))
|
|
#expect(conversation.transcript.first?.isCompaction == true)
|
|
#expect(conversation.transcript.first?.text.contains("Fix the disk") == true)
|
|
// What is shown did not. Compaction shapes the request, never history.
|
|
#expect(conversation.messages.count == visibleBefore)
|
|
#expect(conversation.compactions.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func preparingACompactionDoesNotMutateTheConversation() throws {
|
|
let conversation = ConversationModel()
|
|
for index in 0..<4 {
|
|
conversation.appendUserTurn("turn \(index) " + filler(tokens: 300))
|
|
}
|
|
let span = try #require(conversation.compactionSpan(keepRecentTokens: 300))
|
|
let transcript = conversation.transcript
|
|
let estimate = conversation.contextEstimate
|
|
|
|
let record = try #require(
|
|
conversation.prepareCompaction(
|
|
summary: "summary", cut: span.cut
|
|
))
|
|
|
|
#expect(conversation.transcript == transcript)
|
|
#expect(conversation.contextEstimate == estimate)
|
|
#expect(conversation.compactions.isEmpty)
|
|
#expect(record.compactedThroughMessageID == transcript[span.cut - 1].id)
|
|
}
|
|
|
|
@Test
|
|
func aRepeatedCompactionInheritsTheRawBoundaryWhenItOnlyFoldsTheOldSummary() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("old " + filler(tokens: 600))
|
|
conversation.appendUserTurn("recent " + filler(tokens: 20))
|
|
let firstSpan = try #require(conversation.compactionSpan(keepRecentTokens: 20))
|
|
let first = try #require(
|
|
conversation.applyCompaction(
|
|
summary: "first summary", cut: firstSpan.cut
|
|
))
|
|
|
|
let second = try #require(
|
|
conversation.prepareCompaction(
|
|
summary: "second summary", cut: 1
|
|
))
|
|
|
|
#expect(second.compactedThroughMessageID == first.compactedThroughMessageID)
|
|
#expect(second.sequence == first.sequence + 1)
|
|
}
|
|
|
|
@Test
|
|
func compactionActivityIsEphemeralAndNeverBecomesAMessage() throws {
|
|
let conversation = ConversationModel()
|
|
conversation.appendUserTurn("old " + filler(tokens: 800))
|
|
conversation.appendUserTurn("recent " + filler(tokens: 20))
|
|
let messageCount = conversation.messages.count
|
|
|
|
conversation.beginCompaction()
|
|
#expect(conversation.isCompacting)
|
|
#expect(
|
|
conversation.timeline.contains {
|
|
if case .compactionActivity(let activity) = $0,
|
|
case .running = activity.state
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
let span = try #require(conversation.compactionSpan(keepRecentTokens: 20))
|
|
conversation.applyCompaction(summary: "summary", cut: span.cut)
|
|
conversation.endCompaction()
|
|
|
|
#expect(!conversation.isCompacting)
|
|
#expect(conversation.compactionActivity == nil)
|
|
#expect(conversation.messages.count == messageCount)
|
|
#expect(conversation.compactions.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func failedCompactionKeepsAUiResultWithoutWritingAssistantHistory() {
|
|
let conversation = ConversationModel()
|
|
conversation.beginCompaction()
|
|
conversation.failCompaction("There is not enough history to compact yet.")
|
|
|
|
#expect(!conversation.isCompacting)
|
|
#expect(conversation.messages.isEmpty)
|
|
#expect(conversation.transcript.isEmpty)
|
|
#expect(!conversation.isEmpty)
|
|
guard case .failed(let reason)? = conversation.compactionActivity?.state else {
|
|
Issue.record("Expected a failed compaction activity")
|
|
return
|
|
}
|
|
#expect(reason == "There is not enough history to compact yet.")
|
|
}
|
|
|
|
/// The marker sorts above the history it kept rather than to the end of the
|
|
/// conversation, so the line reads as a boundary and not as a footnote.
|
|
@Test
|
|
func theMarkerSortsWhereTheCutHappened() throws {
|
|
let conversation = ConversationModel()
|
|
for index in 0..<6 {
|
|
conversation.appendUserTurn("turn \(index) \(filler(tokens: 300))")
|
|
}
|
|
let span = try #require(conversation.compactionSpan(keepRecentTokens: 600))
|
|
conversation.applyCompaction(summary: "summary", cut: span.cut)
|
|
|
|
let markIndex = try #require(
|
|
conversation.timeline.firstIndex { if case .compaction = $0 { true } else { false } }
|
|
)
|
|
#expect(markIndex > 0)
|
|
#expect(markIndex < conversation.timeline.count - 1)
|
|
}
|
|
|
|
}
|