Files
Termind/TFMTests/AgentToolProgressTests.swift
T

183 lines
6.8 KiB
Swift

import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
private let emptySchema = AgentJSONValue.object([
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
])
private actor RequestLog {
var values: [AgentModelRequest] = []
func append(_ value: AgentModelRequest) { values.append(value) }
}
private struct TwoTurnModel: AgentModelStreaming {
let requests: RequestLog
let counter = Counter()
func stream(
_ request: AgentModelRequest
) -> AsyncThrowingStream<AgentModelStreamEvent, any Error> {
AsyncThrowingStream { continuation in
Task {
await requests.append(request)
if await counter.next() == 0 {
continuation.yield(
.toolCallSnapshot(
id: "1", providerItemID: nil, name: "slow", arguments: "{}"
))
continuation.yield(.finished(.toolCalls))
} else {
continuation.yield(.textDelta("done"))
continuation.yield(.finished(.completed))
}
continuation.finish()
}
}
}
actor Counter {
var value = 0
func next() -> Int {
defer { value += 1 }
return value
}
}
}
/// Pi's `onUpdate`, ported: a tool may say what it has so far, and the loop
/// neither waits for it nor tells the model about it.
@Suite
struct AgentToolProgressTests {
@Test
func partialsReachTheCardAndOnlyTheFinalResultReachesTheModel() async {
let requests = RequestLog()
let tool = AnyAgentTool(
descriptor: AgentToolDescriptor(
name: "slow", summary: "slow", inputSchema: emptySchema,
target: .local, approvalPolicy: .approve, concurrency: .parallel
)
) { invocation, context in
context.report(AgentToolResult(callID: invocation.call.id, content: "10 of 20"))
context.report(AgentToolResult(callID: invocation.call.id, content: "19 of 20"))
return AgentToolResult(callID: invocation.call.id, content: "20 of 20")
}
let runtime = AgentRuntime(
model: TwoTurnModel(requests: requests), tools: [tool],
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow })
)
var progress: [String] = []
var finished: [String] = []
for await event in await runtime.start(
AgentRunRequest(
conversationID: UUID(), prompt: "go", permissionMode: .askForApproval
))
{
if case .toolProgress(_, let partial) = event { progress.append(partial.content) }
if case .toolFinished(_, let result) = event { finished.append(result.content) }
}
#expect(progress == ["10 of 20", "19 of 20"])
#expect(finished == ["20 of 20"])
// The model is owed one result per call, not a running commentary.
let replayed = await requests.values.last?.messages.filter { $0.role == .tool } ?? []
#expect(replayed.map(\.text) == ["20 of 20"])
}
@MainActor
@Test
func aProgressReportFillsTheCardWithoutFinishingIt() {
let conversation = ConversationModel()
let invocation = AgentToolInvocation(
runID: UUID(), call: AgentToolCall(id: "1", name: "slow", arguments: .object([:]))
)
let descriptor = AgentToolDescriptor(
name: "slow", summary: "slow", inputSchema: emptySchema,
target: .local, approvalPolicy: .approve
)
conversation.handle(.toolProposed(invocation, descriptor))
conversation.handle(.toolStarted(invocation))
conversation.handle(
.toolProgress(
invocation, AgentToolResult(callID: "1", content: "half way")
))
#expect(conversation.toolCards.count == 1)
#expect(conversation.toolCards[0].result?.content == "half way")
// Still running: a card that showed "Completed" the moment it had
// something to show would be lying about the command.
#expect(conversation.toolCards[0].state == .running)
#expect(conversation.transcript.isEmpty)
}
}
/// The buffer between `libssh2`'s queue and the events the card is drawn from.
@Suite
struct AgentCommandOutputStreamTests {
@Test
func reportsCarryEverythingSoFarRatherThanTheLatestChunk() async {
let reports = Reports()
let stream = AgentCommandOutputStream(interval: .zero) { out, error in
reports.append(out, error)
}
stream.handler(Data("one\n".utf8), false)
stream.handler(Data("two\n".utf8), false)
stream.handler(Data("oops\n".utf8), true)
// The card replaces its result wholesale, so a report carrying only the
// newest chunk would make the output flicker rather than grow.
#expect(reports.values.map(\.0) == ["one\n", "one\ntwo\n", "one\ntwo\n"])
#expect(reports.values.map(\.1) == ["", "", "oops\n"])
#expect(stream.captured.standardOut == "one\ntwo\n")
#expect(stream.captured.standardError == "oops\n")
}
@Test
func chunksInsideTheIntervalAreCapturedButNotReported() async {
let reports = Reports()
let stream = AgentCommandOutputStream(interval: .seconds(60)) { out, error in
reports.append(out, error)
}
stream.handler(Data("first".utf8), false)
stream.handler(Data(" second".utf8), false)
#expect(reports.values.count == 1, "the second chunk is inside the window")
// Throttling a report must never drop the output it was reporting.
#expect(stream.captured.standardOut == "first second")
}
@Test
func captureStopsAtTheLimitTheSessionItselfUses() {
let stream = AgentCommandOutputStream(interval: .zero, limit: 4) { _, _ in }
stream.handler(Data("abc".utf8), false)
stream.handler(Data("def".utf8), false)
#expect(stream.captured.standardOut == "abcd")
}
/// Locked rather than an actor: the callback under test may not await, so
/// a collector that had to be awaited would be testing something else — and
/// would race the assertions, which is how this test first failed.
private nonisolated final class Reports: @unchecked Sendable {
private let lock = NSLock()
private var stored: [(String, String)] = []
var values: [(String, String)] { lock.withLock { stored } }
func append(_ out: String, _ error: String) { lock.withLock { stored.append((out, error)) } }
}
}