Files
Termind/TFMTests/AgentPaneToolsTests.swift

377 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite @MainActor
struct AgentPaneToolsTests {
private final class Fixture {
let first = UUID()
let second = UUID()
var current: UUID?
var open: Set<UUID>
var connected: Set<UUID>
var texts: [UUID: String]
init() {
open = [first, second]
connected = [first, second]
texts = [first: "first \n\nlast", second: ""]
current = first
}
func access() -> AgentPaneAccess {
AgentPaneAccess(
list: { [self] in
[first, second].compactMap { id in
guard open.contains(id), connected.contains(id), texts[id] != nil else { return nil }
return pane(id)
}
},
read: { [self] id in
guard open.contains(id) else { return .closed }
guard connected.contains(id) else { return .notConnected }
guard let text = texts[id] else { return .noSurface }
return .text(pane(id), text)
})
}
func pane(_ id: UUID) -> AgentPaneAccess.Pane {
AgentPaneAccess.Pane(
id: id, title: id == first ? "front" : "hidden", hostID: id,
workspace: id == first ? "one" : "two", window: id == first ? "window-1" : "window-2",
isActive: id == first, isCurrent: id == current)
}
}
private func invocation(_ name: String, paneID: String? = nil) -> AgentToolInvocation {
AgentToolInvocation(
runID: UUID(),
call: .init(
id: UUID().uuidString, name: name,
arguments: .object(paneID.map { ["pane_id": .string($0)] } ?? [:])))
}
private func context(_ invocation: AgentToolInvocation) -> AgentToolExecutionContext {
AgentToolExecutionContext(
runID: invocation.runID, userIntent: "Inspect the terminal",
secretBroker: SecretBroker(), userInteraction: UnavailableAgentUserInteraction(),
preflightMetadata: [:], authorize: { _ in .allow })
}
private func payload(_ result: AgentToolResult) throws -> [String: AgentJSONValue] {
let raw = try #require(result.untrustedPayload)
return try #require(AgentJSONValue.decode(Data(raw.utf8)).objectValue)
}
@Test
func listingOnlyReturnsReadableMetadataAndMarksWindowFocus() async throws {
let fixture = Fixture()
let call = invocation("list_panes")
let result = try await ListPanesTool(panes: fixture.access()).execute(call, context: context(call))
let rows = try #require(payload(result)["panes"]?.arrayValue)
#expect(rows.count == 2)
#expect(rows[0].objectValue?["is_active"] == .bool(true))
#expect(rows[0].objectValue?["is_current"] == .bool(true))
#expect(rows[1].objectValue?["is_current"] == .bool(false))
#expect(rows[1].objectValue?["title"] == .string("hidden"))
#expect(!result.content.contains("first"))
fixture.connected.remove(fixture.second)
fixture.texts[fixture.first] = nil
let filtered = try await ListPanesTool(panes: fixture.access()).execute(call, context: context(call))
#expect(try #require(payload(filtered)["panes"]?.arrayValue).isEmpty)
}
@Test
func readUsesTheExplicitTargetEvenAfterFocusMovesWithoutListingAgain() async throws {
let fixture = Fixture()
var listings = 0
let access = fixture.access()
let tool = ReadPaneTool(
panes: AgentPaneAccess(
list: {
listings += 1
return access.list()
}, read: access.read))
let explicit = invocation("read_pane", paneID: fixture.first.uuidString)
fixture.current = fixture.second
let second = invocation("read_pane", paneID: fixture.second.uuidString)
let empty = try await tool.execute(second, context: context(second))
#expect(try payload(empty)["pane_id"] == .string(fixture.second.uuidString))
#expect(try payload(empty)["text"] == .string(""))
#expect(try payload(empty)["truncated"] == .bool(false))
let first = try await tool.execute(explicit, context: context(explicit))
#expect(try payload(first)["text"] == .string("first \n\nlast"))
#expect(try payload(first)["pane_id"] == .string(fixture.first.uuidString))
#expect(listings == 0)
}
@Test
func missingIDIsRejectedEvenWhenACurrentTerminalExists() async throws {
var listings = 0
var reads = 0
let fixture = Fixture()
let tool = ReadPaneTool(
panes: AgentPaneAccess(
list: {
listings += 1
return [fixture.pane(fixture.first)]
},
read: { id in
reads += 1
return .text(fixture.pane(id), "must not be read")
}))
let call = invocation("read_pane")
#expect(tool.descriptor.inputSchema.objectValue?["required"]?.arrayValue?.contains(.string("pane_id")) == true)
await #expect(throws: AgentToolError.self) {
try await tool.execute(call, context: context(call))
}
#expect(listings == 0)
#expect(reads == 0)
}
@Test
func invalidIDClosedDisconnectedAndNoSurfaceDoNotFallback() async throws {
let fixture = Fixture()
let tool = ReadPaneTool(panes: fixture.access())
fixture.current = nil
let implicit = invocation("read_pane")
await #expect(throws: AgentToolError.self) {
try await tool.execute(implicit, context: context(implicit))
}
for id in ["invalid", UUID().uuidString] {
let call = invocation("read_pane", paneID: id)
await #expect(throws: AgentToolError.self) {
try await tool.execute(call, context: context(call))
}
}
fixture.connected.remove(fixture.first)
let disconnected = invocation("read_pane", paneID: fixture.first.uuidString)
await #expect(throws: AgentToolError.self) {
try await tool.execute(disconnected, context: context(disconnected))
}
fixture.connected.insert(fixture.first)
fixture.texts[fixture.first] = nil
await #expect(throws: AgentToolError.self) {
try await tool.execute(disconnected, context: context(disconnected))
}
fixture.open.remove(fixture.first)
await #expect(throws: AgentToolError.self) {
try await tool.execute(disconnected, context: context(disconnected))
}
}
@Test
func readPreservesTheCompleteViewportWithoutTruncation() async throws {
let fixture = Fixture()
let input = "α \n" + String(repeating: "😀", count: 20_000)
fixture.texts[fixture.first] = input
let tool = ReadPaneTool(panes: fixture.access())
let call = invocation("read_pane", paneID: fixture.first.uuidString)
let result = try await tool.execute(call, context: context(call))
let projected = await AgentToolOutputProjection().project(
result, invocation: call, strategy: tool.truncateStrategy)
#expect(tool.truncateStrategy == .none)
#expect(projected.modelContent == result.content)
let payload = try #require(result.untrustedPayload)
#expect(try AgentJSONValue.decode(Data(payload.utf8)).objectValue?["text"] == .string(input))
#expect(!result.isTruncated)
}
@Test
func cardsShowOnlyNamesIDsAndStatesEvenWithoutPresenter() async throws {
let fixture = Fixture()
let readCall = invocation("read_pane", paneID: fixture.first.uuidString)
let readTool = ReadPaneTool(panes: fixture.access())
let readResult = try await readTool.execute(readCall, context: context(readCall))
let card = AgentToolCard(
invocation: readCall, descriptor: readTool.descriptor, state: .completed, result: readResult)
#expect(
TermindToolPresentationProfiles.renderer.summary(for: card, locale: Locale(identifier: "en"))
== "Read terminal front")
#expect(
TermindToolPresentationProfiles.renderer.summary(for: card, locale: Locale(identifier: "zh-Hans"))
== "读取终端“front”")
let detail = try #require(
TermindToolPresentationProfiles.renderer.detail(for: card, locale: Locale(identifier: "en")))
#expect(detail.items.count == 2)
#expect(!String(describing: detail).contains("first "))
#expect(String(describing: detail).contains(fixture.first.uuidString))
let direct = ReadPaneTool.present(
.init(
result: try AgentJSONValue.decode(Data(try #require(readResult.untrustedPayload).utf8)),
arguments: [:], locale: Locale(identifier: "en")))
#expect(!String(describing: direct).contains("first "))
let directHans = ReadPaneTool.present(
.init(
result: try AgentJSONValue.decode(Data(try #require(readResult.untrustedPayload).utf8)),
arguments: [:], locale: Locale(identifier: "zh-Hans")))
#expect(String(describing: directHans).contains("终端名称"))
#expect(!String(describing: directHans).contains("first "))
let restoredStyle = AgentToolCard(
invocation: readCall, descriptor: readTool.descriptor, state: .completed,
result: AgentToolResult(callID: readCall.call.id, content: readResult.content))
let restoredDetail = try #require(
TermindToolPresentationProfiles.renderer.detail(
for: restoredStyle, presenter: TermindAgentTools.presenter(for: readTool.descriptor)))
#expect(!String(describing: restoredDetail).contains("first "))
let listCall = invocation("list_panes")
let listTool = ListPanesTool(panes: fixture.access())
let listResult = try await listTool.execute(listCall, context: context(listCall))
let listCard = AgentToolCard(
invocation: listCall, descriptor: listTool.descriptor, state: .completed, result: listResult)
#expect(
TermindToolPresentationProfiles.renderer.summary(for: listCard, locale: Locale(identifier: "en"))
== "List terminals")
#expect(
TermindToolPresentationProfiles.renderer.summary(for: listCard, locale: Locale(identifier: "zh-Hant"))
== "列出終端")
let listDetail = try #require(
TermindToolPresentationProfiles.renderer.detail(for: listCard, locale: Locale(identifier: "en")))
#expect(String(describing: listDetail).contains("hidden"))
#expect(String(describing: listDetail).contains("Active"))
#expect(String(describing: listDetail).contains("Current"))
#expect(!String(describing: listDetail).contains("\"panes\""))
let directHant = ListPanesTool.present(
.init(
result: try AgentJSONValue.decode(Data(try #require(listResult.untrustedPayload).utf8)),
arguments: [:], locale: Locale(identifier: "zh-Hant")))
#expect(String(describing: directHant).contains("此視窗已啟用"))
#expect(String(describing: directHant).contains("目前助理視窗"))
}
@Test
func batchesDescribeListingAndReadingIncludingRestoredCardsAndFailures() throws {
let fixture = Fixture()
let descriptors = [
ListPanesTool(panes: fixture.access()).descriptor,
ReadPaneTool(panes: fixture.access()).descriptor,
]
let restored = try JSONDecoder().decode(
[AgentToolDescriptor].self, from: JSONEncoder().encode(descriptors))
for values in [descriptors, restored] {
let cards = values.map { descriptor in
AgentToolCard(
invocation: invocation(descriptor.name), descriptor: descriptor, state: .completed)
}
#expect(
TermindToolPresentationProfiles.renderer.batchSummary(for: cards, locale: Locale(identifier: "en"))
== "Listed terminals, read terminal")
#expect(
TermindToolPresentationProfiles.renderer.batchSummary(for: cards, locale: Locale(identifier: "zh-Hans"))
== "已列出终端,已读取终端")
#expect(
TermindToolPresentationProfiles.renderer.batchSummary(for: cards, locale: Locale(identifier: "zh-Hant"))
== "已列出終端,已讀取終端")
let failed = AgentToolCard(
invocation: invocation("read_pane"), descriptor: cards[1].descriptor, state: .failed)
#expect(
TermindToolPresentationProfiles.renderer.batchSummary(
for: [cards[0], cards[1], cards[1], failed], locale: Locale(identifier: "en"))
== "Listed terminals, read terminal (2 times), failed to read terminal")
}
let mcp = AgentToolDescriptor(
name: "read_pane", summary: "Unrelated MCP tool", inputSchema: .object([:]),
target: .mcp, approvalPolicy: .approve)
let card = AgentToolCard(invocation: invocation("read_pane"), descriptor: mcp, state: .completed)
#expect(
TermindToolPresentationProfiles.renderer.batchSummary(for: [card], locale: Locale(identifier: "en"))
!= "Read terminal")
}
@Test
func largePaneReadsBypassProjectionAndDoNotImpersonateMCPMarkers() async throws {
let fixture = Fixture()
fixture.texts[fixture.first] = String(repeating: "😀", count: 20_000)
let call = invocation("read_pane", paneID: fixture.first.uuidString)
let tool = ReadPaneTool(panes: fixture.access())
let result = try await tool.execute(call, context: context(call))
#expect(!result.isTruncated)
#expect(try payload(result)["truncated"] == .bool(false))
#expect(try #require(payload(result)["text"]?.stringValue).utf8.count == 80_000)
let projected = await AgentToolOutputProjection(maximumBytes: 128).project(
result, invocation: call, strategy: tool.truncateStrategy)
let model = try #require(projected.modelContent)
#expect(model == result.content)
#expect(projected.truncateMarker == nil)
let card = AgentToolCard(invocation: call, descriptor: tool.descriptor, state: .completed, result: result)
#expect(!TermindToolPresentationProfiles.renderer.showsOutputTruncatedNotice(for: card))
let mcp = AgentToolDescriptor(
name: "read_pane", summary: "A separate MCP tool", inputSchema: .object([:]),
target: .mcp, approvalPolicy: .approve, concurrency: .parallel)
var mcpCard = AgentToolCard(invocation: call, descriptor: mcp, state: .completed, result: result)
mcpCard.result?.truncateMarker = AgentTruncateMarker.render(ranges: [1...10], totalLines: 20)
#expect(TermindToolPresentationProfiles.renderer.showsOutputTruncatedNotice(for: mcpCard))
}
@Test
func paneToolsAreAvailableInPlanningAndActingOnlyWhenInjected() {
let resolver = AgentHostResolver(store: HostLibraryModel())
let connections = AgentConnectionPool(
configuration: { _ in throw AgentToolError.invalidHost },
routeFingerprint: { _ in "" },
openConnection: { _ in throw AgentToolError.invalidHost },
openSFTP: { _ in throw AgentToolError.invalidHost },
sweepInterval: nil)
func registry(_ panes: AgentPaneAccess?) -> AgentToolRegistry {
AgentToolRegistry(
TermindAgentTools.tools(
in: .init(
hosts: resolver, metrics: TestMetricsReader(), connections: connections, panes: panes)))
}
#expect(registry(nil)["list_panes"] == nil)
let injected = registry(Fixture().access())
#expect(injected.available(in: .planning)["list_panes"] != nil)
#expect(injected.available(in: .planning)["read_pane"] != nil)
#expect(injected.available(in: .acting)["list_panes"] != nil)
#expect(injected.available(in: .acting)["read_pane"] != nil)
#expect(injected.available(in: .reviewing)["read_pane"] == nil)
}
@Test
func desktopAdapterExcludesDisconnectedAndSurfaceLessSSHtelnetSerialPanes() throws {
let host = Host(name: "test", host: "test.invalid", telnetEnabled: true)
let app = AppModel(
hosts: HostLibraryModel(hosts: [host]),
dependencies: .inMemory(hosts: [host], groups: []))
let window = WindowModel()
let ssh = SSHSessionModel(hostID: host.id, title: "ssh")
let telnet = TelnetSessionModel(host: host)
let serial = SerialSessionModel(
configuration: SerialConnectionConfiguration(devicePath: "/dev/termind-test"),
service: SystemSerialPortService())
let panes = [Pane(.sshSession(ssh)), Pane(.telnetSession(telnet)), Pane(.serialSession(serial))]
let workspaces = panes.map(Workspace.init(pane:))
window.workspaces = workspaces
window.selectedDestination = .workspace(workspaces[0].id)
app.registerWindow(window)
let access = app.paneAccess(for: window)
#expect(access.list().isEmpty)
for pane in panes {
#expect(access.read(pane.id) == .notConnected)
}
ssh.status = .connected
telnet.status = .connected
serial.status = .connected
#expect(access.list().isEmpty)
for pane in panes {
#expect(access.read(pane.id) == .noSurface)
}
app.unregisterWindow(window)
}
}