Files
Termind/TermindTests/AgentInterruptedRunRecoveryTests.swift
T
Kaede AkinoandClaude Opus 5 bfbebc7b80 Answer tool calls that a killed process left hanging
Force quitting during a tool batch produced a conversation that could not
be continued at all, only deleted: `failed to stream request: error: No
tool output found for function call call_…`. The assistant turn is
persisted as soon as it arrives, its results only when the run next saves
a snapshot, so a process that dies in between restores a turn whose calls
nothing answers — and every provider rejects that outright rather than
degrading. It is the exact mirror of the orphaned result this codebase
already repaired, and the same class of fatal.

Recorded results come back first. `toolFinished` is durable and lands as
each tool finishes, so in the common case the output is sitting in the
database while the transcript claims to know nothing about it;
`loadConversations` now materializes any result whose transcript row was
never written. In a batch of three killed during the third, the first two
come back with the output they actually produced.

`AgentUnansweredToolCallRepair` answers whatever storage cannot, at the
model boundary and at the snapshot seed — the same two places the orphan
repair runs, and for the same reason: repairing at the seed writes the
fix forward instead of re-deriving it on every future turn. What it says
matters as much as that it says something. It does not claim the tool did
not run. The process died after the command was dispatched, so whether
the remote side executed it is genuinely unknown, and a model told "this
never ran" will cheerfully run it again — which is a data-loss bug the
first time the call was `rm`. It says the outcome is unknown and to check
before repeating.

A card caught mid-flight also came back spinning. Every unfinished run is
marked interrupted at launch, so `running` is not a state a restored card
can legitimately be in, and nothing was ever going to stop that spinner.

Worth noting against the previous commit: batching the snapshot save to
once per turn rather than once per tool widened this window, and the
recovery above is what closes it — results are still recorded one at a
time, so nothing a tool produced is lost, only deferred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:36:53 +08:00

159 lines
6.7 KiB
Swift

import Foundation
import SwiftData
import Testing
@testable import Termind
/// 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 = ModelConfiguration(
"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, safety: .requiresAuthorization
),
state: "started", result: nil
)],
lastRunState: .interrupted
)])
let card = try! #require(store.conversations.first?.toolCards.first)
#expect(card.state != .running)
}
}