Files
Termind/TFMTests/AgentBackgroundCommandTests.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

443 lines
21 KiB
Swift

import AgentKit
import Foundation
import TermindApplication
import TermindRemoteInfrastructure
import Testing
private nonisolated func exited(_ code: Int32 = 0) -> SSHExecResult {
SSHExecResult(
stdout: "", stderr: "", exitCode: code, signal: nil,
stdoutTruncated: false, stderrTruncated: false)
}
@Suite
struct AgentBackgroundCommandTests {
@Test
func outputToolCapturesEachFullSnapshotWithItsOwnNameUnderTheConversation() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let workspace = AgentScratchWorkspace(base: directory)
let scope = UUID()
let accumulator = AgentOutputAccumulator(
workspace: workspace,
path: {
"\(scope.uuidString.lowercased())/outputs/ssh-exec-\(UUID().uuidString.lowercased()).txt"
})
let stream = AgentCommandOutputStream(accumulator: accumulator) { _, _ in }
let commands = AgentBackgroundCommands(sweepInterval: nil)
let source = "first\n" + String(repeating: "output\n", count: 2_100) + "last>"
let handle = try await commands.start(
scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: false, stream: stream
) { session in
session.start { _ in 0 }
stream.handler(Data(source.utf8), false)
return exited()
}
try await commands.waitForCompletion(handle, scope: scope)
let run = await stream.snapshot()
#expect(run.capture?.path?.hasPrefix("\(scope.uuidString.lowercased())/outputs/ssh-exec-") == true)
let tool = SSHExecOutputTool(background: commands, scope: scope)
let call = AgentToolInvocation(
runID: UUID(),
call: .init(
id: "output", name: "ssh_exec_output",
arguments: .object([
"handle": .string(handle.uuidString)
])))
let context = AgentToolExecutionContext(
runID: call.runID, conversationID: scope, userIntent: "read output", secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(), preflightMetadata: [:],
authorize: { _ in .allow }, workspace: workspace)
let first = try await tool.execute(call, context: context)
let second = try await tool.execute(call, context: context)
let path = try #require(first.outputCapture?.path)
#expect(path.hasPrefix("\(scope.uuidString.lowercased())/outputs/ssh-exec-output-"))
#expect(path.hasSuffix(".txt"))
#expect(second.outputCapture?.path != path)
#expect(try await workspace.data(at: path).data == Data(source.utf8))
#expect(first.metadata["output_total_lines"] == .number(2_102))
await commands.closeAll()
}
@Test
func outputIsImmediateCumulativeAndRepeatableAfterCompletion() async throws {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let release = AsyncRelease()
let stream = AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
let handle = try await commands.start(
scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: false, stream: stream
) { session in
session.start { _ in 0 }
await release.wait()
return exited(7)
}
stream.handler(Data("step one\n".utf8), false)
let running = try await commands.output(handle, scope: scope)
#expect(running.objectValue?["status"] == .string("running"))
#expect(running.objectValue?["output"] == .string("step one\n"))
#expect(running.objectValue?["stdin_closed"] == .bool(true))
#expect(try await commands.output(handle, scope: scope) == running)
stream.handler(Data("step two\n".utf8), false)
stream.handler(Data("failure\n".utf8), true)
await release.release()
try await commands.waitForCompletion(handle, scope: scope)
let finished = try await commands.output(handle, scope: scope)
#expect(finished.objectValue?["status"] == .string("exited"))
#expect(finished.objectValue?["exit_code"] == .number(7))
#expect(finished.objectValue?["output"] == .string("step one\nstep two\nfailure\n"))
#expect(try await commands.output(handle, scope: scope) == finished)
#expect(await commands.count == 1)
await commands.closeAll()
}
@Test
func failedStartupLeavesNoHandle() async {
let commands = AgentBackgroundCommands(sweepInterval: nil)
await #expect(throws: CocoaError.self) {
try await commands.start(
scope: UUID(), hostID: UUID(), command: "build", keepsStdinOpen: true,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { _ in throw CocoaError(.fileReadNoPermission) }
}
#expect(await commands.count == 0)
}
@Test
func cancellationDuringStartupDoesNotWaitForAuthenticationOrAllowLateStartup() async throws {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let connecting = AsyncRelease()
let release = AsyncRelease()
let finished = AsyncRelease()
let task = Task {
try await commands.start(
scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: true,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { session in
await connecting.release()
await release.wait()
session.start { _ in 0 }
#expect(!session.started)
await finished.release()
return exited()
}
}
await connecting.wait()
#expect(await commands.list(scope: scope).objectValue?["commands"] == .array([]))
task.cancel()
await #expect(throws: CancellationError.self) { try await task.value }
#expect(await commands.count == 0)
await release.release()
await finished.wait()
}
@Test
func runningCommandsNeverExpireAndFinishedReadsDoNotExtendRetention() async throws {
let commands = AgentBackgroundCommands(idleTimeout: .seconds(600), sweepInterval: nil)
let scope = UUID()
let release = AsyncRelease()
let handle = try await commands.start(
scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: true,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { session in
session.start { _ in 0 }
await release.wait()
return exited()
}
#expect(await commands.sweepIdle(now: .now.advanced(by: .seconds(86_400))) == 1)
await release.release()
try await commands.waitForCompletion(handle, scope: scope)
_ = try await commands.output(handle, scope: scope)
#expect(await commands.list(scope: scope).objectValue?["commands"] == .array([]))
#expect(
await commands.list(scope: scope, includeFinished: true).objectValue?["commands"]?.arrayValue?.count == 1)
#expect(await commands.sweepIdle(now: .now.advanced(by: .seconds(601))) == 0)
await #expect(throws: AgentBackgroundCommandError.self) {
try await commands.output(handle, scope: scope)
}
}
@Test
func scopeIsolationAndCancellationRetireOnlyTheLocalHandle() async throws {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let otherScope = UUID()
let host = UUID()
let release = AsyncRelease()
let handle = try await commands.start(
scope: scope, hostID: host, command: "build", keepsStdinOpen: true,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { session in
session.start { _ in 0 }
await release.wait()
return exited()
}
#expect(await commands.list(scope: otherScope).objectValue?["commands"] == .array([]))
#expect(await commands.list(scope: scope, hostID: UUID()).objectValue?["commands"] == .array([]))
let rows = await commands.list(scope: scope, hostID: host).objectValue?["commands"]?.arrayValue
#expect(rows?.count == 1)
#expect(rows?.first?.objectValue?["output"] == nil)
await #expect(throws: AgentBackgroundCommandError.self) {
try await commands.cancel(handle, scope: otherScope)
}
let result = try await commands.cancel(handle, scope: scope)
#expect(result.objectValue?["status"] == .string("cancelled"))
#expect(result.objectValue?["was_running"] == .bool(true))
#expect(result.objectValue?["remote_termination_confirmed"] == .bool(false))
await #expect(throws: AgentBackgroundCommandError.self) {
try await commands.cancel(handle, scope: scope)
}
await release.release()
#expect(await commands.count == 0)
}
@Test
func stdinPreservesExactBytesAndEOFIsIdempotent() async throws {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let release = AsyncRelease()
let writes = InputLog()
let handle = try await commands.start(
scope: scope, hostID: UUID(), command: "cat", keepsStdinOpen: true,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { session in
session.start { try await writes.write($0) }
await release.wait()
return exited()
}
let result = try await commands.write(handle, scope: scope, input: .text("abc"))
#expect(result.objectValue?["bytes_written"] == .number(3))
_ = try await commands.write(handle, scope: scope, input: .text(""))
_ = try await commands.write(handle, scope: scope, input: nil)
let repeated = try await commands.write(handle, scope: scope, input: nil)
#expect(repeated.objectValue?["stdin_closed"] == .bool(true))
#expect(await writes.values == ["abc", "", nil])
await #expect(throws: (any Error).self) {
try await commands.write(handle, scope: scope, input: .text("too late"))
}
await release.release()
try await commands.waitForCompletion(handle, scope: scope)
#expect(try await commands.cancel(handle, scope: scope).objectValue?["status"] == .string("released"))
}
@Test
func interjectionReturnsRunningSnapshotWithoutCancellingCommand() async throws {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let release = AsyncRelease()
let signal = AgentInterjectionSignal()
let handle = try await commands.start(
scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: false,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { session in
session.start { _ in 0 }
await release.wait()
#expect(!Task.isCancelled)
return exited()
}
signal.arm()
try await commands.waitForCompletion(handle, scope: scope, interjection: { await signal.wait() })
#expect(try await commands.output(handle, scope: scope).objectValue?["status"] == .string("running"))
await release.release()
try await commands.waitForCompletion(handle, scope: scope)
await commands.closeAll()
}
@Test
func outputAndListAreReadOnlyParallelToolsWithoutTimeouts() {
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let tools = [
AnyAgentTool(SSHExecOutputTool(background: commands, scope: scope)),
AnyAgentTool(SSHExecListTool(background: commands, scope: scope)),
]
for tool in tools {
#expect(tool.descriptor.approvalPolicy == .approve)
#expect(tool.descriptor.concurrency == .parallel)
#expect(tool.descriptor.inputSchema.objectValue?["properties"]?.objectValue?["timeout_seconds"] == nil)
}
}
@Test(arguments: ["text", "empty", "secret", "eof"], [false, true])
func stdinToolAcceptsOmittedOrNullUnusedPayloads(mode: String, explicitNulls: Bool) async throws {
let secretText = "stdin-regression-secret"
let commands = AgentBackgroundCommands(sweepInterval: nil)
let scope = UUID()
let hostID = UUID()
let release = AsyncRelease()
let writes = InputLog()
let handle = try await commands.start(
scope: scope, hostID: hostID, command: "cat", keepsStdinOpen: true,
stream: AgentCommandOutputStream(
accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" })
) { _, _ in }
) { session in
session.start { try await writes.write($0) }
await release.wait()
return exited()
}
let broker = SecretBroker()
do {
let tool = SSHExecStdinTool(background: commands, scope: scope)
var arguments: [String: AgentJSONValue] = [
"handle": .string(handle.uuidString), "type": .string(mode == "empty" ? "text" : mode),
]
if explicitNulls {
arguments["stdin"] = .null
arguments["secret_handle"] = .null
}
switch mode {
case "text", "empty": arguments["stdin"] = .string(mode == "empty" ? "" : "input")
case "secret":
var bytes = Data(secretText.utf8)
let secret = try await broker.issue(AgentSecret(consumingUTF8: &bytes))
arguments["secret_handle"] = .string(secret.id.uuidString)
default: break
}
let invocation = AgentToolInvocation(
runID: UUID(), call: .init(id: "stdin", name: "ssh_exec_stdin", arguments: .object(arguments)),
targetHostID: UUID())
try AgentJSONSchemaValidator.validate(invocation.call.arguments, against: tool.descriptor.inputSchema)
let checked = try await tool.preflight(invocation)
#expect(checked.invocation.targetHostID == hostID)
#expect(checked.approvalPolicy == .ask)
let review = GuardianPolicy.reviewPrompt(
.init(
invocation: checked.invocation, descriptor: tool.descriptor, userIntent: "Provide command input",
localReasons: checked.reasons),
evidence: [])
#expect(review.contains(hostID.uuidString))
#expect(review.contains("Write input to: cat"))
#expect(review.contains("The plaintext is intentionally hidden from review") == (mode == "secret"))
#expect(!review.contains(secretText))
if mode == "secret" {
#expect(await broker.count == 1)
#expect(await writes.secretWrites == 0)
}
let result = try await tool.execute(
checked.invocation,
context: .init(
runID: invocation.runID, userIntent: "Provide command input", secretBroker: broker,
userInteraction: UnavailableAgentUserInteraction(), preflightMetadata: [:],
authorize: { _ in .allow }))
let payload = try AgentJSONValue.decode(Data(try #require(result.untrustedPayload).utf8))
#expect(!result.isError)
#expect(payload.objectValue?["status"] == .string(mode == "eof" ? "closed" : "written"))
#expect(payload.objectValue?["stdin_closed"] == .bool(mode == "eof"))
switch mode {
case "text": #expect(await writes.values == ["input"])
case "empty": #expect(await writes.values == [""])
case "secret":
#expect(await writes.secretWrites == 1)
#expect(await broker.count == 0)
#expect(payload.objectValue?["bytes_written"] == .number(Double(secretText.utf8.count)))
default: #expect(await writes.values == [nil])
}
} catch {
await release.release()
await commands.closeAll()
await broker.discardAll()
throw error
}
await release.release()
await commands.closeAll()
}
@Test(arguments: [false, true])
func listToolAcceptsOmittedOrNullFilters(explicitNulls: Bool) async throws {
let tool = SSHExecListTool(background: AgentBackgroundCommands(sweepInterval: nil), scope: UUID())
let invocation = AgentToolInvocation(
runID: UUID(),
call: .init(
id: "list", name: "ssh_exec_list",
arguments: .object(explicitNulls ? ["host_id": .null, "include_finished": .null] : [:])))
try AgentJSONSchemaValidator.validate(invocation.call.arguments, against: tool.descriptor.inputSchema)
let result = try await tool.execute(
invocation,
context: .init(
runID: invocation.runID, userIntent: "List commands", secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(), preflightMetadata: [:], authorize: { _ in .allow }))
let payload = try AgentJSONValue.decode(Data(try #require(result.untrustedPayload).utf8))
#expect(payload == .object(["commands": .array([])]))
}
@Test(arguments: [
["type": AgentJSONValue.string("text"), "stdin": .null, "secret_handle": .null],
["type": .string("secret"), "stdin": .null, "secret_handle": .null],
["type": .string("secret"), "stdin": .null, "secret_handle": .string("invalid")],
["type": .string("text"), "stdin": .string("input"), "secret_handle": .string(UUID().uuidString)],
["type": .string("secret"), "stdin": .string(""), "secret_handle": .string(UUID().uuidString)],
["type": .string("eof"), "stdin": .string(""), "secret_handle": .null],
["type": .string("eof"), "stdin": .null, "secret_handle": .string(UUID().uuidString)],
["type": .string("eof"), "stdin": .bool(false), "secret_handle": .null],
])
func stdinToolStillRejectsMissingRequiredOrConflictingPayloads(arguments: [String: AgentJSONValue]) async {
let tool = SSHExecStdinTool(background: AgentBackgroundCommands(sweepInterval: nil), scope: UUID())
var arguments = arguments
arguments["handle"] = .string(UUID().uuidString)
let invocation = AgentToolInvocation(
runID: UUID(), call: .init(id: "stdin", name: "ssh_exec_stdin", arguments: .object(arguments)))
do {
_ = try await tool.preflight(invocation)
Issue.record("Invalid input must be rejected before command lookup")
} catch AgentToolError.invalidArguments {
} catch {
Issue.record("Unexpected error: \(error)")
}
}
}
private actor AsyncRelease {
private var isReleased = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
if isReleased { return }
await withCheckedContinuation { waiters.append($0) }
}
func release() {
isReleased = true
let pending = waiters
waiters.removeAll()
for waiter in pending { waiter.resume() }
}
}
private actor InputLog {
var values: [String?] = []
var secretWrites = 0
func write(_ input: SSHCommandInput?) throws -> Int {
switch input {
case .text(let text):
values.append(text)
return text.utf8.count
case .plain(let data):
values.append(String(decoding: data, as: UTF8.self))
return data.count
case nil:
values.append(nil)
return 0
case .secret(let secret):
secretWrites += 1
return try secret.withUnsafeBytes { $0.count }
}
}
}