Files
Termind/TFMTests/AgentInterruptedRunRecoveryTests.swift

167 lines
7.0 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import SwiftData
import TermindApplication
import TermindPersistence
import Testing
/// What a force quit during a tool batch leaves behind, and what it takes to
/// make that conversation usable again.
///
/// The failure it guards is total, not partial: an assistant turn carrying a
/// tool call that nothing answers is rejected outright by every provider —
/// OpenAI says `No tool output found for function call call_…` — so the
/// conversation cannot be continued at all, only deleted.
@Suite
struct AgentInterruptedRunRecoveryTests {
private func assistant(calling ids: [String]) -> AgentTranscriptMessage {
AgentTranscriptMessage(
role: .assistant, text: "Checking both.",
toolCalls: ids.map { AgentToolCall(id: $0, name: "ssh_exec", arguments: .object([:])) }
)
}
private func answer(_ id: String, _ text: String) -> AgentTranscriptMessage {
AgentTranscriptMessage(role: .tool, text: text, toolCallID: id, toolName: "ssh_exec")
}
@Test
func everyToolCallEndsUpAnsweredAtTheModelBoundary() {
let messages = [
AgentTranscriptMessage(role: .user, text: "check both hosts"),
assistant(calling: ["call-a", "call-b"]),
answer("call-a", "up 3 days"),
]
let sent = AgentContextPipeline.chat(sessionContext: nil, before: nil)(
AgentModelContext(systemPrompt: "s", messages: messages, tools: [])
).messages
let answered = Set(sent.compactMap(\.toolCallID))
#expect(answered == ["call-a", "call-b"])
// Placed against the call it answers, not appended to the end.
#expect(sent.map(\.role) == [.user, .assistant, .tool, .tool])
// The recovered output is untouched; only the missing one is invented.
#expect(sent[2].text == "up 3 days")
#expect(sent[3].isError)
}
/// It must not claim the action did not happen. The process died after the
/// command was sent, so whether the remote side ran it is genuinely
/// unknown, and a model told "this never ran" will cheerfully run it again.
@Test
func theSynthesizedAnswerDoesNotClaimTheToolNeverRan() {
let repaired = AgentUnansweredToolCallRepair.apply([assistant(calling: ["call-a"])])
let text = try! #require(repaired.last?.text)
#expect(text.contains("unknown"))
#expect(!text.lowercased().contains("never ran"))
}
@Test
func repairingIsIdempotentAndLeavesCompleteTurnsAlone() {
let messages = [assistant(calling: ["call-a"]), answer("call-a", "ok")]
let once = AgentUnansweredToolCallRepair.apply(messages)
#expect(once == messages)
#expect(AgentUnansweredToolCallRepair.apply(once) == once)
}
/// Results are recorded as each tool finishes, but their transcript rows are
/// only written when the run next saves its snapshot. A process killed in
/// between leaves the call answered in storage and unanswered in history —
/// so restore should hand back the real output, not a placeholder for it.
@MainActor
@Test
func restoreRecoversResultsRecordedBeforeTheProcessDied() async throws {
let configuration = localModelConfiguration(
"AgentInterruptedRunRecoveryTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
// The snapshot as it stood when the assistant turn was persisted: two
// calls, no results yet.
let snapshot = AgentRunSnapshot(
conversationID: UUID(), state: .running, permissionMode: .askForApproval,
messages: [
AgentTranscriptMessage(role: .user, text: "check both hosts"),
assistant(calling: ["call-a", "call-b"]),
]
)
try await repository.save(snapshot)
// One tool finished and was recorded; the other was still running when
// the app was killed, so no snapshot save ever followed.
let finished = AgentToolInvocation(
runID: snapshot.id,
call: AgentToolCall(id: "call-a", name: "ssh_exec", arguments: .object([:]))
)
try await repository.record(.toolStarted(finished), runID: snapshot.id)
try await repository.record(
.toolFinished(finished, AgentToolResult(callID: "call-a", content: "up 3 days")),
runID: snapshot.id
)
let running = AgentToolInvocation(
runID: snapshot.id,
call: AgentToolCall(id: "call-b", name: "ssh_exec", arguments: .object([:]))
)
try await repository.record(.toolStarted(running), runID: snapshot.id)
// Relaunch.
let reopened = SwiftDataAgentRunRepository(context: container.mainContext)
let restored = try #require(try await reopened.loadConversations().first)
let recovered = try #require(restored.messages.first { $0.toolCallID == "call-a" })
#expect(recovered.text == "up 3 days", "the recorded output should come back")
#expect(!recovered.isError)
#expect(restored.lastRunState == .interrupted)
// The one that never finished is still unanswered here — storage has
// nothing to recover — and is repaired at the model boundary.
let sent = AgentContextPipeline.chat(sessionContext: nil, before: nil)(
AgentModelContext(systemPrompt: "s", messages: restored.messages, tools: [])
).messages
#expect(Set(sent.compactMap(\.toolCallID)) == ["call-a", "call-b"])
}
/// A card left mid-flight by a force quit must not come back spinning.
/// Every run is marked interrupted at launch, so `running` is never a state
/// a restored card can legitimately be in.
@MainActor
@Test
func restoredCardsDoNotComeBackRunning() {
let store = AssistantConversationStore()
let invocation = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(id: "call-b", name: "ssh_exec", arguments: .object([:]))
)
store.restore([
StoredAgentConversation(
id: UUID(),
messages: [],
toolCards: [
StoredAgentToolCard(
invocation: invocation,
descriptor: AgentToolDescriptor(
name: "ssh_exec", summary: "exec", inputSchema: .object([:]),
target: .host, approvalPolicy: .ask
),
state: "started", result: nil
)
],
lastRunState: .interrupted
)
])
let card = try! #require(store.conversations.first?.toolCards.first)
#expect(card.state == .interrupted)
}
}