279 lines
14 KiB
Swift
279 lines
14 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import Termind
|
|
|
|
@MainActor
|
|
final class TestMetricsReader: MetricsSnapshotReading {
|
|
var value: MetricsSnapshot?
|
|
var reads: [UUID] = []
|
|
init(_ value: MetricsSnapshot? = nil) { self.value = value }
|
|
func snapshot(for hostID: UUID) -> MetricsSnapshot? {
|
|
reads.append(hostID)
|
|
return value
|
|
}
|
|
}
|
|
|
|
@Suite
|
|
@MainActor
|
|
struct GetMetricsToolTests {
|
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
|
|
|
@Test
|
|
func overviewAndTrendUseOnlyRecentFiniteSamples() throws {
|
|
let old = MetricsPoint(date: now.addingTimeInterval(-301), cpu: 99)
|
|
let first = MetricsPoint(
|
|
date: now.addingTimeInterval(-300), cpu: 20, memory: 40, swapPercent: 50, diskRead: 100)
|
|
let last = MetricsPoint(
|
|
date: now, cpu: 60, memory: nil, swapPercent: 75, swapUsed: 3_072, swapTotal: 4_096,
|
|
swapFree: 1_024, diskRead: .infinity)
|
|
let future = MetricsPoint(date: now.addingTimeInterval(1), cpu: 100)
|
|
let snapshot = MetricsSnapshot(capturedAt: now, state: .live, point: last, history: [future, last, old, first])
|
|
let object = output(snapshot)
|
|
let overview = try #require(object["overview"]?.objectValue)
|
|
#expect(overview["cpu_percent"] == .number(60))
|
|
#expect(overview["memory_percent"] == .null)
|
|
#expect(overview["swap_percent"] == .number(75))
|
|
#expect(overview["swap_used_bytes"] == .number(3_072))
|
|
#expect(overview["swap_total_bytes"] == .number(4_096))
|
|
#expect(overview["swap_free_bytes"] == .number(1_024))
|
|
#expect(overview["disk_read_bytes_per_second"] == .null)
|
|
#expect(overview["sampled_at"] == .string(now.ISO8601Format()))
|
|
#expect(overview["age_seconds"] == .number(0))
|
|
#expect(overview["status"] == .string("available"))
|
|
let trend = try #require(object["trend"]?.objectValue)
|
|
#expect(trend["sample_count"] == .number(2))
|
|
#expect(trend["from"] == .string(first.date.ISO8601Format()))
|
|
#expect(trend["to"] == .string(last.date.ISO8601Format()))
|
|
#expect(trend["cpu_percent"]?.objectValue?["mean"] == .number(40))
|
|
#expect(trend["cpu_percent"]?.objectValue?["peak"] == .number(60))
|
|
#expect(trend["memory_percent"]?.objectValue?["sample_count"] == .number(1))
|
|
#expect(trend["swap_percent"]?.objectValue?["mean"] == .number(62.5))
|
|
#expect(trend["swap_percent"]?.objectValue?["peak"] == .number(75))
|
|
#expect(trend["disk_read_bytes_per_second"]?.objectValue?["mean"] == .number(100))
|
|
#expect(trend["network_receive_bytes_per_second"]?.objectValue?["mean"] == .null)
|
|
for section in GetMetricsTool.sections { #expect(object[section] == nil) }
|
|
}
|
|
|
|
@Test
|
|
func detailAgesFailuresAndTruncationAreIndependent() throws {
|
|
var snapshot = MetricsSnapshot(
|
|
capturedAt: now, state: .paused, point: MetricsPoint(date: now.addingTimeInterval(-31)))
|
|
snapshot.gpuState = .failed
|
|
snapshot.gpuDate = now.addingTimeInterval(-40)
|
|
snapshot.gpu = NvidiaSnapshot(devices: [NvidiaGPU(id: "gpu", name: "NVIDIA", utilization: 80)])
|
|
snapshot.processesDate = now.addingTimeInterval(-30)
|
|
snapshot.processes = (1...25).map { MetricsProcess(pid: $0, started: 1, name: "worker", cpu: Double(200 - $0)) }
|
|
snapshot.filesystemsDate = now.addingTimeInterval(-60)
|
|
snapshot.filesystems = [
|
|
MetricsFilesystem(mountPoint: "/", device: "/dev/vda1", type: "ext4", blockID: "8:1", used: 1024)
|
|
]
|
|
let object = output(snapshot, include: GetMetricsTool.sections)
|
|
#expect(object["status"] == .string("paused"))
|
|
#expect(object["overview"]?.objectValue?["status"] == .string("stale"))
|
|
#expect(object["devices"]?.objectValue?["age_seconds"] == .number(31))
|
|
let gpu = try #require(object["gpu"]?.objectValue)
|
|
#expect(gpu["status"] == .string("failed"))
|
|
#expect(gpu["stale"] == .bool(true))
|
|
#expect(gpu["age_seconds"] == .number(40))
|
|
#expect(gpu["devices"]?.arrayValue?.first?.objectValue?["utilization_percent"] == .number(80))
|
|
let processes = try #require(object["processes"]?.objectValue)
|
|
#expect(processes["status"] == .string("available"))
|
|
#expect(processes["total_count"] == .number(25))
|
|
#expect(processes["truncated"] == .bool(true))
|
|
#expect(processes["items"]?.arrayValue?.count == 20)
|
|
#expect(processes["items"]?.arrayValue?.first?.objectValue?["cpu_percent"] == .number(199))
|
|
#expect(object["filesystems"]?.objectValue?["status"] == .string("available"))
|
|
snapshot.filesystemsDate = now.addingTimeInterval(-61)
|
|
snapshot.processesDate = now.addingTimeInterval(-31)
|
|
let stale = output(snapshot, include: ["filesystems", "processes"])
|
|
#expect(stale["filesystems"]?.objectValue?["status"] == .string("stale"))
|
|
#expect(stale["processes"]?.objectValue?["status"] == .string("stale"))
|
|
snapshot.processesFailed = true
|
|
snapshot.filesystemsFailed = true
|
|
let failed = output(snapshot, include: ["filesystems", "processes"])
|
|
#expect(failed["filesystems"]?.objectValue?["status"] == .string("failed"))
|
|
#expect(failed["processes"]?.objectValue?["status"] == .string("failed"))
|
|
}
|
|
|
|
@Test
|
|
func missingDataAndExpiredHistoryStayUnknown() {
|
|
var snapshot = MetricsSnapshot(capturedAt: now, state: .collecting)
|
|
snapshot.history = [MetricsPoint(date: now.addingTimeInterval(-301), cpu: 80)]
|
|
snapshot.gpuState = .unavailable
|
|
let object = output(snapshot, include: GetMetricsTool.sections)
|
|
for section in ["overview", "processes", "filesystems", "devices", "gpu"] {
|
|
#expect(object[section]?.objectValue?["status"] == .string("unavailable"))
|
|
}
|
|
#expect(object["overview"]?.objectValue?["cpu_percent"] == .null)
|
|
#expect(object["trend"]?.objectValue?["from"] == .null)
|
|
#expect(object["trend"]?.objectValue?["sample_count"] == .number(0))
|
|
#expect(object["trend"]?.objectValue?["cpu_percent"]?.objectValue?["peak"] == .null)
|
|
#expect(object["overview"]?.objectValue?["swap_percent"] == .null)
|
|
#expect(object["trend"]?.objectValue?["swap_percent"]?.objectValue?["peak"] == .null)
|
|
}
|
|
|
|
@Test
|
|
func zeroCapacitySwapRemainsMachineReadableWithoutAUtilization() throws {
|
|
let snapshot = MetricsSnapshot(
|
|
capturedAt: now, state: .live,
|
|
point: MetricsPoint(date: now, swapUsed: 0, swapTotal: 0, swapFree: 0))
|
|
let overview = try #require(output(snapshot)["overview"]?.objectValue)
|
|
#expect(overview["swap_used_bytes"] == .number(0))
|
|
#expect(overview["swap_total_bytes"] == .number(0))
|
|
#expect(overview["swap_free_bytes"] == .number(0))
|
|
#expect(overview["swap_percent"] == .null)
|
|
}
|
|
|
|
@Test
|
|
func knownUnmonitoredAndUnknownHostsHaveDifferentOutcomes() async throws {
|
|
let host = Host(name: "Known", host: "known.example")
|
|
let reader = TestMetricsReader()
|
|
let server = makeMCPServerTools(hosts: [host], metrics: reader)
|
|
let result = try await call(server, hostID: host.id.uuidString)
|
|
#expect(!result.isError)
|
|
#expect(try payload(result)["status"] == .string("not_monitored"))
|
|
#expect(reader.reads == [host.id])
|
|
let unknown = try await call(server, hostID: UUID().uuidString)
|
|
let invalid = try await call(server, hostID: "invalid")
|
|
#expect(unknown.isError)
|
|
#expect(invalid.isError)
|
|
#expect(reader.reads == [host.id])
|
|
}
|
|
|
|
@Test
|
|
func invalidIncludesAreRejectedBeforeReading() async throws {
|
|
let host = Host(name: "Host", host: "host.example")
|
|
let reader = TestMetricsReader()
|
|
let server = makeMCPServerTools(hosts: [host], metrics: reader)
|
|
for include: AgentJSONValue in [.string("gpu"), .array([.string("secrets")]), .array([.number(1)]), .null] {
|
|
let result = try await call(server, hostID: host.id.uuidString, include: include)
|
|
#expect(result.isError)
|
|
}
|
|
#expect(reader.reads.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func readOnlyToolExecutesWhilePlanningWithoutApprovalOrAuthentication() async throws {
|
|
let host = Host(name: "Host", host: "host.example")
|
|
let reader = TestMetricsReader(
|
|
MetricsSnapshot(capturedAt: now, state: .live, point: MetricsPoint(date: now, cpu: 42)))
|
|
let tool = GetMetricsTool(hosts: AgentHostResolver(store: HostLibraryModel(hosts: [host])), metrics: reader)
|
|
let call = AgentToolCall(
|
|
id: "metrics", name: "get_metrics", arguments: .object(["host_id": .string(host.id.uuidString)]))
|
|
let invocation = AgentToolInvocation(runID: UUID(), call: call)
|
|
let checked = try await tool.preflight(invocation)
|
|
#expect(checked.invocation.targetHostID == host.id)
|
|
#expect(tool.descriptor.approvalPolicy == .approve)
|
|
#expect(tool.descriptor.concurrency == .parallel)
|
|
let decision = await AgentPlanModeHook(isPlanning: true).willExecute(
|
|
AgentToolCallContext(
|
|
invocation: checked.invocation, descriptor: tool.descriptor, userIntent: "Inspect",
|
|
permissionMode: .askForApproval)
|
|
)
|
|
#expect(decision == .proceed)
|
|
let executor = AgentToolExecutor(
|
|
tools: AgentToolRegistry([AnyAgentTool(tool)]),
|
|
approval: AgentApprovalBroker(
|
|
reviewer: nil,
|
|
manualApproval: { _ in
|
|
Issue.record("Reading cached metrics must not request approval")
|
|
return .deny("unexpected")
|
|
}),
|
|
hooks: AgentLoopHooks([AgentPlanModeHook(isPlanning: true)]),
|
|
channel: AgentEventChannel(repository: InMemoryAgentRunRepository()),
|
|
secretBroker: SecretBroker(), userInteraction: UnavailableAgentUserInteraction(),
|
|
runID: invocation.runID, permissionMode: .askForApproval, userIntent: "Inspect"
|
|
)
|
|
let result = await executor.execute(executor.plan(call))
|
|
#expect(!result.isError)
|
|
#expect(try payload(result)["overview"]?.objectValue?["cpu_percent"] == .number(42))
|
|
#expect(reader.reads == [host.id])
|
|
}
|
|
|
|
@Test
|
|
func mcpDisableAndReenableKeepTheSameReader() async throws {
|
|
let host = Host(name: "Host", host: "host.example")
|
|
let reader = TestMetricsReader()
|
|
let hosts = AgentHostResolver(store: HostLibraryModel(hosts: [host]))
|
|
let pool = AgentConnectionPool(
|
|
configuration: { _ in throw MetricsReadError.output },
|
|
routeFingerprint: { _ in throw MetricsReadError.output },
|
|
openConnection: { _ in
|
|
Issue.record("Unexpected SSH connection")
|
|
throw MetricsReadError.output
|
|
},
|
|
openSFTP: { _ in
|
|
Issue.record("Unexpected SFTP connection")
|
|
throw MetricsReadError.output
|
|
}, sweepInterval: nil
|
|
)
|
|
let server = makeMCPServerTools(hosts: [host], metrics: reader)
|
|
for disabled in [Set(["get_metrics"]), Set<String>()] {
|
|
server.replaceDisabledTools(
|
|
disabled, hosts: hosts, metrics: reader, connections: pool,
|
|
authentication: AgentSSHAuthenticationRegistry(resolver: hosts),
|
|
background: AgentBackgroundCommands(sweepInterval: nil))
|
|
#expect(server.contains("get_metrics") == disabled.isEmpty)
|
|
if !disabled.isEmpty {
|
|
await #expect(throws: MCPServerToolsError.self) { try await call(server, hostID: host.id.uuidString) }
|
|
}
|
|
}
|
|
reader.value = MetricsSnapshot(capturedAt: now, state: .sleeping)
|
|
#expect(try payload(try await call(server, hostID: host.id.uuidString))["status"] == .string("sleeping"))
|
|
#expect(reader.reads == [host.id])
|
|
}
|
|
|
|
@Test
|
|
func persistedPresenterPreservesHostValuesAndStaleWarningAcrossLocales() throws {
|
|
let presenter = try #require(TermindAgentTools.presenter(id: "builtin.get_metrics"))
|
|
let snapshot = MetricsSnapshot(
|
|
capturedAt: now, state: .live,
|
|
point: MetricsPoint(
|
|
date: now.addingTimeInterval(-31), cpu: 25, memory: 50, swapPercent: 75,
|
|
swapUsed: 3_072, swapTotal: 4_096, swapFree: 1_024))
|
|
let value = AgentJSONValue.object(output(snapshot))
|
|
for language in ["en", "zh-Hans", "zh-Hant"] {
|
|
let locale = Locale(identifier: language)
|
|
let hostLabel = AppLocalization.string("Host", locale: locale)
|
|
let staleLabel = AppLocalization.string("Cached metrics are stale", locale: locale)
|
|
let detail = presenter.present(
|
|
AgentToolDetailInput(result: value, arguments: [:], locale: locale))
|
|
#expect(detail.contains(.field(.init(label: hostLabel, value: "Host"))))
|
|
#expect(detail.contains(.message(staleLabel, .warning)))
|
|
#expect(detail.contains(.field(.init(label: "CPU", value: "25.0%"))))
|
|
#expect(
|
|
detail.contains(
|
|
.field(.init(label: AppLocalization.string("Swap", locale: locale), value: "75.0%"))))
|
|
}
|
|
}
|
|
|
|
private func output(_ snapshot: MetricsSnapshot, include: Set<String> = []) -> [String: AgentJSONValue] {
|
|
MetricsToolOutput.make(hostID: UUID(), name: "Host", snapshot: snapshot, include: include).objectValue ?? [:]
|
|
}
|
|
|
|
private func call(_ server: MCPServerTools, hostID: String, include: AgentJSONValue? = nil) async throws
|
|
-> AgentToolResult
|
|
{
|
|
var arguments: [String: AgentJSONValue] = ["host_id": .string(hostID)]
|
|
if let include { arguments["include"] = include }
|
|
return try await server.call(
|
|
name: "get_metrics", arguments: .object(arguments), runID: UUID(), clientName: "Tests",
|
|
callID: UUID().uuidString, secretBroker: SecretBroker())
|
|
}
|
|
|
|
private func payload(_ result: AgentToolResult) throws -> [String: AgentJSONValue] {
|
|
let json = try #require(result.untrustedPayload)
|
|
return try #require(try AgentJSONValue.decode(Data(json.utf8)).objectValue)
|
|
}
|
|
}
|