951 lines
38 KiB
Swift
951 lines
38 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
|
|
|
|
@Suite
|
|
struct AgentToolPresentationTests {
|
|
@Test
|
|
func approvalUsesTheRegisteredToolArgumentProjectionWithoutRedactingIt() throws {
|
|
let hostID = UUID()
|
|
let command = "sudo deploy --token=visible-to-the-user"
|
|
let request = AgentApprovalRequest(
|
|
invocation: AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "approval",
|
|
name: "ssh_exec",
|
|
arguments: .object([
|
|
"host_id": .string(hostID.uuidString),
|
|
"command": .string(command),
|
|
"stdin": .string("password=hunter2"),
|
|
"secret_handle": .string("one-shot-handle"),
|
|
"stdin_purpose": .string("sudo password"),
|
|
])
|
|
),
|
|
targetHostID: hostID
|
|
),
|
|
descriptor: AgentToolDescriptor(
|
|
name: "ssh_exec", summary: "Execute over SSH",
|
|
inputSchema: .object([:]), target: .host,
|
|
approvalPolicy: .ask,
|
|
presentation: .init(
|
|
symbol: "terminal",
|
|
activity: .semanticArgument(key: "command", fallback: .sshCommand),
|
|
actionKind: .run
|
|
)
|
|
),
|
|
userIntent: "Deploy"
|
|
)
|
|
|
|
let presentation = AgentToolPresentation.approval(
|
|
for: request,
|
|
presenter: SSHExecTool.presenter,
|
|
hostName: "Production",
|
|
locale: Locale(identifier: "en")
|
|
)
|
|
|
|
#expect(!presentation.title.contains(command))
|
|
#expect(!presentation.title.contains(hostID.uuidString))
|
|
#expect(
|
|
presentation.detail.items == [
|
|
.text(.init(title: "Command", text: command, style: .monospaced)),
|
|
.text(.init(title: "Input", text: "password=hunter2", style: .monospaced)),
|
|
.field(.init(label: "Secret handle", value: "one-shot-handle", isMonospaced: true)),
|
|
.field(.init(label: "Purpose", value: "sudo password")),
|
|
]
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func approvalFallsBackToGenericFieldsForAnUnregisteredTool() {
|
|
let request = AgentApprovalRequest(
|
|
invocation: AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "approval", name: "remote_tool",
|
|
arguments: .object(["password": .string("hunter2")])
|
|
)
|
|
),
|
|
descriptor: AgentToolDescriptor(
|
|
name: "remote_tool", summary: "Remote tool",
|
|
inputSchema: .object([:]), target: .mcp,
|
|
approvalPolicy: .ask
|
|
),
|
|
userIntent: "Run it"
|
|
)
|
|
|
|
let presentation = AgentToolPresentation.approval(
|
|
for: request, locale: Locale(identifier: "en")
|
|
)
|
|
#expect(!presentation.title.contains("hunter2"))
|
|
#expect(
|
|
presentation.detail.items == [
|
|
.field(.init(label: "Password", value: "hunter2"))
|
|
]
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func userInputDetailShowsOneQuestionAndAnswerFieldPerLine() throws {
|
|
let arguments: [String: AgentJSONValue] = [
|
|
"questions": .array([
|
|
.object([
|
|
"id": .string("storage"), "header": .string("Storage"),
|
|
"prompt": .string("Which format should we use?"),
|
|
"options": .array([
|
|
.object(["id": .string("jsonb"), "label": .string("JSONB")])
|
|
]),
|
|
]),
|
|
.object([
|
|
"id": .string("region"), "prompt": .string("Which region?"),
|
|
]),
|
|
])
|
|
]
|
|
var card = makeCard(
|
|
name: "request_user_input", target: .user, arguments: arguments,
|
|
presentation: RequestUserInputTool().descriptor.presentation
|
|
)
|
|
card.state = .completed
|
|
card.result = AgentToolResult(
|
|
callID: "call",
|
|
content: AgentJSONValue.object([
|
|
"answers": .array([
|
|
.object([
|
|
"question_id": .string("storage"), "answer": .string("With audit log"),
|
|
"selected_option_ids": .array([.string("jsonb")]),
|
|
"custom_text": .string("With audit log"),
|
|
]),
|
|
.object([
|
|
"question_id": .string("region"), "answer": .null,
|
|
"selected_option_ids": .array([]), "custom_text": .null,
|
|
]),
|
|
])
|
|
]).encodedString
|
|
)
|
|
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)))
|
|
#expect(
|
|
detail.items == [
|
|
.field(.init(label: "Question", value: "Storage — Which format should we use?")),
|
|
.field(.init(label: "Answer", value: "JSONB, With audit log")),
|
|
.field(.init(label: "Question", value: "Which region?")),
|
|
.field(.init(label: "Answer", value: "Skipped")),
|
|
])
|
|
#expect(!String(describing: detail).contains("selected_option_ids"))
|
|
#expect(!String(describing: detail).contains("jsonb"))
|
|
}
|
|
|
|
@Test
|
|
func hostListingDetailPreservesGroupsAndHidesProtocolIdentifiers() throws {
|
|
let productionID = "11111111-1111-1111-1111-111111111111"
|
|
let childID = "22222222-2222-2222-2222-222222222222"
|
|
let result = AgentJSONValue.object([
|
|
"groups": .array([
|
|
.object([
|
|
"id": .string(productionID), "name": .string("Production"),
|
|
"path": .string("Production"),
|
|
"children": .array([
|
|
.object([
|
|
"id": .string(childID), "name": .string("Edge"),
|
|
"path": .string("Production / Edge"), "children": .array([]),
|
|
])
|
|
]),
|
|
])
|
|
]),
|
|
"hosts": .array([
|
|
hostJSON(
|
|
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", name: "API Server",
|
|
address: "deploy@api.example.com", state: "connected",
|
|
tags: ["prod", "nginx"], groupID: productionID
|
|
),
|
|
hostJSON(
|
|
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", name: "Loose Box",
|
|
address: "root@10.0.0.2", state: "failed", tags: [],
|
|
groupID: "ffffffff-ffff-ffff-ffff-ffffffffffff"
|
|
),
|
|
hostJSON(
|
|
id: "cccccccc-cccc-cccc-cccc-cccccccccccc", name: "Current Terminal",
|
|
address: "root@10.0.0.8", state: "reconnecting", tags: [],
|
|
temporary: true
|
|
),
|
|
]),
|
|
])
|
|
var card = makeCard(name: "list_hosts", target: .host)
|
|
card.state = .completed
|
|
card.result = AgentToolResult(callID: "call", content: result.encodedString)
|
|
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)))
|
|
guard case .groupedList(let listing) = try #require(detail.items.first) else {
|
|
Issue.record("Expected a grouped host listing")
|
|
return
|
|
}
|
|
#expect(
|
|
listing.sections.map { $0.rows.map(\.title) } == [
|
|
["API Server"], [], ["Loose Box"], ["Current Terminal"],
|
|
])
|
|
#expect(listing.sections[0].rows[0].badges == ["prod", "nginx"])
|
|
let rendered = String(describing: detail)
|
|
#expect(!rendered.contains(productionID))
|
|
#expect(!rendered.contains("aaaaaaaa-aaaa"))
|
|
#expect(!rendered.contains("group_id"))
|
|
#expect(!rendered.contains("connection_state"))
|
|
}
|
|
|
|
@Test
|
|
func hostListingBecomesScrollablePastTwelveHosts() throws {
|
|
let hosts = (0..<13).map { index in
|
|
hostJSON(
|
|
id: UUID().uuidString, name: "Host \(index)",
|
|
address: "host\(index).example.com", state: "disconnected", tags: []
|
|
)
|
|
}
|
|
var card = makeCard(name: "list_hosts", target: .host)
|
|
card.result = AgentToolResult(
|
|
callID: "call",
|
|
content: AgentJSONValue.object([
|
|
"groups": .array([]), "hosts": .array(hosts),
|
|
]).encodedString
|
|
)
|
|
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)))
|
|
guard case .groupedList(let listing) = try #require(detail.items.first) else {
|
|
Issue.record("Expected a grouped host listing")
|
|
return
|
|
}
|
|
#expect(listing.isScrollable)
|
|
#expect(listing.sections[0].rows.count == 13)
|
|
}
|
|
|
|
@Test
|
|
func builtInFallbackUsesFieldsWhileMCPKeepsJSON() throws {
|
|
let payload = AgentJSONValue.object([
|
|
"internal_flag": .bool(true), "count": .number(2),
|
|
]).encodedString
|
|
var builtIn = makeCard(name: "future_tool", target: .local)
|
|
builtIn.result = AgentToolResult(callID: "call", content: payload)
|
|
let builtInDetail = try #require(
|
|
AgentToolPresentation.detail(for: builtIn, presenter: TermindAgentTools.presenter(for: builtIn.descriptor)))
|
|
#expect(
|
|
builtInDetail.items == [
|
|
.field(.init(label: "Count", value: "2")),
|
|
.field(.init(label: "Internal Flag", value: "Yes")),
|
|
])
|
|
#expect(!String(describing: builtInDetail).contains("{"))
|
|
|
|
var mcp = makeCard(
|
|
name: "remote_tool", target: .mcp,
|
|
presentation: .init(
|
|
symbol: "puzzlepiece.extension", activity: .label("remote tool"), output: .json
|
|
)
|
|
)
|
|
mcp.result = AgentToolResult(callID: "call", content: payload)
|
|
let mcpDetail = try #require(
|
|
AgentToolPresentation.detail(for: mcp, presenter: TermindAgentTools.presenter(for: mcp.descriptor)))
|
|
guard case .text(let block) = try #require(mcpDetail.items.first) else {
|
|
Issue.record("Expected MCP JSON text")
|
|
return
|
|
}
|
|
#expect(block.text.contains("{"))
|
|
#expect(block.text.contains("\"internal_flag\""))
|
|
}
|
|
|
|
@Test
|
|
func unknownPresenterIDCannotSelectOrImpersonateBuiltInCode() throws {
|
|
var card = makeCard(
|
|
name: "fetch", target: .network,
|
|
presentation: .init(
|
|
symbol: "globe", activity: .label("URL"), output: .json,
|
|
presenterID: "remote.fetch"
|
|
)
|
|
)
|
|
card.result = AgentToolResult(
|
|
callID: "call",
|
|
content: AgentJSONValue.object([
|
|
"url": .string("https://example.com"), "status": .number(200),
|
|
]).encodedString
|
|
)
|
|
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)))
|
|
#expect(
|
|
detail.items == [
|
|
.field(.init(label: "Status", value: "200")),
|
|
.field(.init(label: "Url", value: "https://example.com")),
|
|
])
|
|
}
|
|
|
|
@Test
|
|
func everyBuiltInJSONToolProducesStructuredDetailWithoutJSONSyntax() throws {
|
|
let file = AgentJSONValue.object([
|
|
"path": .string("/tmp/a"), "name": .string("a"), "kind": .string("file"),
|
|
"size": .number(4), "permissions": .string("644"), "modified_at": .null,
|
|
])
|
|
let cases: [(String, AgentToolDescriptor.Target, [String: AgentJSONValue], AgentJSONValue)] = [
|
|
("list_hosts", .host, [:], .object(["groups": .array([]), "hosts": .array([])])),
|
|
(
|
|
"ssh_exec", .host, [:],
|
|
.object([
|
|
"status": .string("exited"), "stdout": .string("ok"),
|
|
"stderr": .string(""), "exit_code": .number(0),
|
|
])
|
|
),
|
|
(
|
|
"ssh_wait", .host, [:],
|
|
.object([
|
|
"status": .string("running"), "stdout": .string(""),
|
|
"stderr": .string(""), "note": .string("Still running"),
|
|
])
|
|
),
|
|
(
|
|
"terminal_suggest", .user, ["command": .string("pwd")],
|
|
.object([
|
|
"command": .string("pwd"), "requires_user_action": .bool(true),
|
|
])
|
|
),
|
|
("sftp_list", .host, [:], .array([file])),
|
|
("sftp_stat", .host, [:], file),
|
|
(
|
|
"sftp_read", .host, [:],
|
|
.object([
|
|
"path": .string("/tmp/a"), "content": .string("text"), "bytes": .number(4),
|
|
])
|
|
),
|
|
(
|
|
"sftp_write", .host, ["content": .string("text")],
|
|
.object([
|
|
"path": .string("/tmp/a"), "written": .bool(true),
|
|
])
|
|
),
|
|
(
|
|
"sftp_mkdir", .host, [:],
|
|
.object([
|
|
"path": .string("/tmp/new"), "created": .bool(true),
|
|
])
|
|
),
|
|
(
|
|
"sftp_move", .host, [:],
|
|
.object([
|
|
"source": .string("/tmp/a"), "destination": .string("/tmp/b"),
|
|
"moved": .bool(true),
|
|
])
|
|
),
|
|
(
|
|
"sftp_chmod", .host, [:],
|
|
.object([
|
|
"path": .string("/tmp/a"), "mode": .string("644"),
|
|
])
|
|
),
|
|
(
|
|
"sftp_delete", .host, ["kind": .string("file")],
|
|
.object([
|
|
"path": .string("/tmp/a"), "deleted": .bool(true),
|
|
])
|
|
),
|
|
(
|
|
"fetch", .network, ["method": .string("GET")],
|
|
.object([
|
|
"url": .string("https://example.com"), "status": .number(200),
|
|
"headers": .object(["content-type": .string("text/plain")]),
|
|
"body": .string("hello"),
|
|
])
|
|
),
|
|
(
|
|
"web_search", .network, [:],
|
|
.array([
|
|
.object([
|
|
"title": .string("Example"), "url": .string("https://example.com"),
|
|
"snippet": .string("Snippet"), "published_at": .null,
|
|
])
|
|
])
|
|
),
|
|
(
|
|
"request_user_input", .user,
|
|
[
|
|
"questions": .array([
|
|
.object(["id": .string("q"), "prompt": .string("Question?")])
|
|
])
|
|
],
|
|
.object([
|
|
"answers": .array([
|
|
.object(["question_id": .string("q"), "answer": .string("Answer")])
|
|
])
|
|
])
|
|
),
|
|
(
|
|
"request_user_secret", .user,
|
|
[
|
|
"prompt": .string("Password"), "purpose": .string("Login"),
|
|
"consumer_tool": .string("ssh_exec"),
|
|
], .object(["secret_handle": .string("hidden"), "single_use": .bool(true)])
|
|
),
|
|
(
|
|
"scratch_list", .local, [:],
|
|
.object([
|
|
"entries": .array([]), "entry_count": .number(0),
|
|
"entry_quota": .number(100), "total_bytes": .number(0),
|
|
"byte_quota": .number(1000),
|
|
])
|
|
),
|
|
(
|
|
"scratch_read", .local, [:],
|
|
.object([
|
|
"path": .string("a"), "content": .string("text"), "bytes": .number(4),
|
|
"offset": .number(1), "returned_lines": .number(1),
|
|
"total_lines": .number(1), "line_ending": .string("lf"),
|
|
])
|
|
),
|
|
(
|
|
"scratch_write", .local, [:],
|
|
.object([
|
|
"path": .string("a"), "bytes": .number(4), "sha256": .string("abcd"),
|
|
"created": .bool(true), "total_bytes": .number(4),
|
|
])
|
|
),
|
|
(
|
|
"scratch_edit", .local, [:],
|
|
.object([
|
|
"path": .string("a"), "edits_applied": .number(1),
|
|
"diff": .string("-old\n+new"), "bytes": .number(3),
|
|
"line_ending": .string("lf"), "sha256": .string("abcd"),
|
|
"total_bytes": .number(3),
|
|
])
|
|
),
|
|
(
|
|
"scratch_diff", .local, [:],
|
|
.object([
|
|
"path_a": .string("a"), "path_b": .string("b"),
|
|
"identical": .bool(true), "diff": .string(""),
|
|
"line_ending_a": .string("lf"), "line_ending_b": .string("lf"),
|
|
])
|
|
),
|
|
(
|
|
"scratch_delete", .local, [:],
|
|
.object([
|
|
"path": .string("a"), "kind": .string("file"), "deleted": .bool(true),
|
|
"entry_count": .number(0), "total_bytes": .number(0),
|
|
])
|
|
),
|
|
(
|
|
"scratch_pull", .host, [:],
|
|
.object([
|
|
"remote_path": .string("/tmp/a"), "scratch_path": .string("a"),
|
|
"bytes": .number(4), "sha256": .string("abcd"),
|
|
"is_utf8": .bool(true), "lines": .number(1), "total_bytes": .number(4),
|
|
])
|
|
),
|
|
(
|
|
"scratch_push", .host, [:],
|
|
.object([
|
|
"scratch_path": .string("a"), "remote_path": .string("/tmp/a"),
|
|
"bytes": .number(4), "sha256": .string("abcd"), "written": .bool(true),
|
|
])
|
|
),
|
|
(
|
|
"present_plan", .user, [:],
|
|
.object([
|
|
"presented": .bool(true), "scratch_path": .string("plan.md"),
|
|
"plan": .string("Plan"),
|
|
])
|
|
),
|
|
(
|
|
"manage_tasks", .user, [:],
|
|
.object([
|
|
"tasks": .number(3), "completed": .number(1),
|
|
])
|
|
),
|
|
]
|
|
|
|
for (name, target, arguments, result) in cases {
|
|
var card = makeCard(name: name, target: target, arguments: arguments)
|
|
card.result = AgentToolResult(callID: "call", content: result.encodedString)
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)),
|
|
"Missing \(name)")
|
|
let rendered = String(describing: detail)
|
|
#expect(!rendered.contains("{"), "\(name) exposed JSON")
|
|
#expect(!rendered.contains("}"), "\(name) exposed JSON")
|
|
}
|
|
}
|
|
|
|
/// Separate from the table above, which builds cards without a presentation and
|
|
/// so resolves through `legacyPresenter`. `scratch_fetch` has never shipped, so
|
|
/// it deliberately has no legacy name and is reached only by presenter ID.
|
|
@Test
|
|
func scratchFetchDetailIsStructuredWithoutJSONSyntax() throws {
|
|
var card = makeCard(
|
|
name: "scratch_fetch", target: .network,
|
|
arguments: ["url": .string("https://example.com/docs")],
|
|
presentation: .init(
|
|
symbol: "globe.badge.chevron.backward",
|
|
activity: .semanticArgument(key: "url", fallback: .url),
|
|
output: .json, presenterID: "builtin.scratch_fetch",
|
|
actionKind: .fetch
|
|
)
|
|
)
|
|
card.result = AgentToolResult(
|
|
callID: "call",
|
|
content: AgentJSONValue.object([
|
|
"url": .string("https://example.com/docs"), "title": .string("Docs"),
|
|
"format": .string("markdown"), "scratch_path": .string("docs.md"),
|
|
"bytes": .number(4), "sha256": .string("abcd"), "lines": .number(1),
|
|
"total_bytes": .number(4),
|
|
]).encodedString
|
|
)
|
|
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)))
|
|
let rendered = String(describing: detail)
|
|
#expect(!rendered.contains("{"))
|
|
#expect(!rendered.contains("}"))
|
|
#expect(rendered.contains("docs.md"))
|
|
#expect(rendered.contains("Docs"))
|
|
}
|
|
|
|
@Test
|
|
func scratchSearchDetailIsStructuredWithoutJSONSyntax() throws {
|
|
var card = makeCard(
|
|
name: "scratch_search", target: .local,
|
|
arguments: ["query": .string("listen")],
|
|
presentation: .init(
|
|
symbol: "magnifyingglass",
|
|
activity: .semanticArgument(key: "query", fallback: .scratchWorkspace),
|
|
output: .json, presenterID: "builtin.scratch_search",
|
|
actionKind: .search
|
|
)
|
|
)
|
|
card.result = AgentToolResult(
|
|
callID: "call",
|
|
content: AgentJSONValue.object([
|
|
"matches": .array([
|
|
.object([
|
|
"path": .string("nginx.conf"), "line": .number(12),
|
|
"text": .string("listen 443 ssl;"),
|
|
"text_truncated": .bool(false),
|
|
])
|
|
]),
|
|
"matching_lines": .number(1), "returned_matches": .number(1),
|
|
"searched_files": .number(2), "skipped_binary_files": .number(1),
|
|
"case_sensitive": .bool(false), "truncated": .bool(false),
|
|
]).encodedString
|
|
)
|
|
|
|
let detail = try #require(
|
|
AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)))
|
|
let rendered = String(describing: detail)
|
|
#expect(rendered.contains("nginx.conf:12"))
|
|
#expect(rendered.contains("listen 443 ssl;"))
|
|
#expect(!rendered.contains("{"))
|
|
#expect(!rendered.contains("}"))
|
|
}
|
|
|
|
@Test
|
|
func batchIconFollowsLiveAndHomogeneousTools() {
|
|
let terminal = AgentToolDescriptor.Presentation(
|
|
symbol: "terminal", activity: .label("a command"),
|
|
actionKind: .run
|
|
)
|
|
var first = makeCard(name: "ssh_exec", target: .host, presentation: terminal)
|
|
var second = makeCard(name: "ssh_exec", target: .host, presentation: terminal)
|
|
first.state = .running
|
|
second.state = .proposed
|
|
#expect(AgentToolPresentation.batchSymbol(for: [first, second]) == "terminal")
|
|
#expect(AgentToolPresentation.batchTint(for: [first, second]) == .secondary)
|
|
|
|
first.state = .completed
|
|
second.state = .completed
|
|
#expect(AgentToolPresentation.batchSymbol(for: [first, second]) == "terminal")
|
|
#expect(AgentToolPresentation.batchTint(for: [first, second]) == .success)
|
|
|
|
first.state = .failed
|
|
second.state = .denied
|
|
#expect(AgentToolPresentation.batchTint(for: [first, second]) == .failure)
|
|
|
|
first.state = .completed
|
|
second.state = .failed
|
|
#expect(AgentToolPresentation.batchTint(for: [first, second]) == .warning)
|
|
|
|
var question = makeCard(
|
|
name: "request_user_input", target: .user,
|
|
presentation: RequestUserInputTool().descriptor.presentation
|
|
)
|
|
question.state = .completed
|
|
#expect(
|
|
AgentToolPresentation.batchSymbol(for: [first, question])
|
|
== "wrench.and.screwdriver"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func reasoningBatchTitleIsPlainSingleLineText() {
|
|
let title = AgentToolPresentation.reasoningTitle(
|
|
from: """
|
|
**Checked both hosts.**
|
|
|
|
- Compare the logs
|
|
- Verify `sshd`
|
|
""")
|
|
|
|
#expect(title == "Checked both hosts. Compare the logs Verify sshd")
|
|
}
|
|
|
|
@Test
|
|
func countedArrayActivityRoundTrips() throws {
|
|
let activity = AgentToolDescriptor.Presentation.Activity.localizedArrayCount(
|
|
key: "questions", unit: .question, fallback: "a question"
|
|
)
|
|
let data = try JSONEncoder().encode(activity)
|
|
#expect(try JSONDecoder().decode(type(of: activity), from: data) == activity)
|
|
}
|
|
|
|
@Test
|
|
func outputUnwrapsAndExtractsCommandStreams() {
|
|
let result = AgentToolResult(
|
|
callID: "call",
|
|
content: "<untrusted-data>\n{\"stdout\":\"ok\\n\",\"stderr\":\"warning\"}\n</untrusted-data>",
|
|
metadata: ["untrusted": .bool(true)]
|
|
)
|
|
|
|
#expect(
|
|
AgentToolPresentation.output(
|
|
from: result,
|
|
presentation: .init(
|
|
symbol: "terminal", activity: .label("command"), output: .streams
|
|
)
|
|
)
|
|
== "Standard output:\nok\n\nStandard error:\nwarning"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func duplicateGuardianDenialIsShownOnlyOnce() {
|
|
let reason = "The request was not explicitly authorized."
|
|
let review = GuardianDecision(
|
|
verdict: .deny, risk: .high, userAuthorization: .low,
|
|
reason: reason
|
|
)
|
|
let duplicate = AgentToolResult(
|
|
callID: "call", content: " The request was not explicitly\nauthorized. ",
|
|
isError: true
|
|
)
|
|
let distinct = AgentToolResult(
|
|
callID: "call", content: "The remote host refused the command.", isError: true
|
|
)
|
|
|
|
#expect(
|
|
AgentToolPresentation.detailOutput(
|
|
from: duplicate, presentation: nil, review: review
|
|
) == nil)
|
|
#expect(
|
|
AgentToolPresentation.detailOutput(
|
|
from: distinct, presentation: nil, review: review
|
|
) == "The remote host refused the command.")
|
|
}
|
|
|
|
@Test
|
|
func allowedReviewNeverHidesAResult() {
|
|
let review = GuardianDecision(
|
|
verdict: .approve, risk: .low, userAuthorization: .high,
|
|
reason: "Completed with a warning."
|
|
)
|
|
let result = AgentToolResult(
|
|
callID: "call", content: review.reason, isError: true
|
|
)
|
|
|
|
#expect(
|
|
AgentToolPresentation.detailOutput(
|
|
from: result, presentation: nil, review: review
|
|
) == review.reason)
|
|
}
|
|
|
|
@Test
|
|
func longOutputPreviewStaysWithinTheRenderingBudgetAndKeepsFullCopy() {
|
|
let full = (0..<180).map { "line \($0) " + String(repeating: "x", count: 80) }
|
|
.joined(separator: "\n")
|
|
|
|
let preview = AgentToolPresentation.preview(full)
|
|
|
|
#expect(preview.isShortened)
|
|
#expect(preview.full == full)
|
|
#expect(preview.displayed.count <= AgentToolPresentation.displayCharacterLimit)
|
|
#expect(
|
|
preview.displayed.reduce(1) { $1 == "\n" ? $0 + 1 : $0 }
|
|
<= AgentToolPresentation.displayLineLimit
|
|
)
|
|
#expect(preview.displayed.hasPrefix("line 0"))
|
|
#expect(preview.displayed.contains("characters omitted"))
|
|
#expect(preview.displayed.hasSuffix(String(repeating: "x", count: 80)))
|
|
#expect(preview.omittedCharacters > 0)
|
|
}
|
|
|
|
@Test
|
|
func outputPreviewCountsUnicodeCharactersWithoutCorruptingEitherEnd() {
|
|
let full = "开头🧭" + String(repeating: "中", count: 200) + "结尾✅"
|
|
let preview = AgentToolPresentation.preview(full, characterLimit: 80, lineLimit: 120)
|
|
|
|
#expect(preview.isShortened)
|
|
#expect(preview.displayed.count <= 80)
|
|
#expect(preview.displayed.hasPrefix("开头🧭"))
|
|
#expect(preview.displayed.hasSuffix("结尾✅"))
|
|
#expect(preview.full == full)
|
|
}
|
|
|
|
@Test
|
|
func shortOutputIsNotProjectedOrMarkedAsShortened() {
|
|
let full = "one\ntwo\nthree"
|
|
let preview = AgentToolPresentation.preview(full)
|
|
|
|
#expect(preview.displayed == full)
|
|
#expect(preview.full == full)
|
|
#expect(!preview.isShortened)
|
|
#expect(preview.omittedCharacters == 0)
|
|
}
|
|
|
|
@Test
|
|
func presentationRoundTripsThroughExistingResultMetadata() {
|
|
let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "terminal",
|
|
activity: .argument(key: "command", fallback: "the SSH command"),
|
|
output: .streams,
|
|
presenterID: "builtin.ssh_exec",
|
|
groupsWithAdjacentTools: false,
|
|
messages: ["running": "Connecting to {target}"]
|
|
)
|
|
var result = AgentToolResult(callID: "call", content: "done")
|
|
result.toolPresentation = presentation
|
|
|
|
#expect(result.toolPresentation == presentation)
|
|
#expect(result.metadata[AgentToolResult.toolPresentationKey] != nil)
|
|
}
|
|
|
|
@Test
|
|
func builtInDetailPresenterCatalogHasUniqueStableIDs() {
|
|
// The composed registry, runtime plus Termind: a duplicate ID across the
|
|
// two catalogs is exactly the collision that would make one app's card
|
|
// draw with another's projection, and it is invisible in either catalog
|
|
// alone.
|
|
let runtime = AgentToolCatalog.builtInPresenters.map(\.id)
|
|
let ids = TermindAgentTools.presenterRegistry.ids
|
|
#expect(Set(ids).count == ids.count)
|
|
#expect(Set(runtime).isSubset(of: Set(ids)))
|
|
#expect(ids.allSatisfy { $0.hasPrefix("builtin.") })
|
|
for id in ids {
|
|
#expect(TermindAgentTools.presenter(id: id) != nil)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func restoredAndLegacyCardsUseTheSameToolOwnedDetailPresenter() throws {
|
|
let payload = AgentJSONValue.array([
|
|
.object([
|
|
"title": .string("Example"), "url": .string("https://example.com"),
|
|
"snippet": .string("A result"), "published_at": .null,
|
|
])
|
|
])
|
|
let livePresentation = WebSearchTool.presenter.id
|
|
var persisted = AgentToolResult(callID: "call", content: payload.encodedString)
|
|
persisted.toolPresentation = .init(
|
|
symbol: "magnifyingglass",
|
|
activity: .semanticArgument(key: "query", fallback: .web), output: .json,
|
|
presenterID: livePresentation, actionKind: .search
|
|
)
|
|
|
|
var restored = makeCard(
|
|
name: "web_search", target: .network,
|
|
arguments: ["query": .string("example")],
|
|
presentation: persisted.toolPresentation
|
|
)
|
|
restored.result = persisted
|
|
|
|
var legacy = makeCard(
|
|
name: "web_search", target: .network,
|
|
arguments: ["query": .string("example")]
|
|
)
|
|
legacy.result = persisted
|
|
legacy.descriptor.presentation = nil
|
|
|
|
#expect(
|
|
AgentToolPresentation.detail(
|
|
for: restored, presenter: TermindAgentTools.presenter(for: restored.descriptor),
|
|
locale: Locale(identifier: "en"))
|
|
== AgentToolPresentation.detail(
|
|
for: legacy, presenter: TermindAgentTools.presenter(for: legacy.descriptor),
|
|
locale: Locale(identifier: "en"))
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func semanticPresentationRoundTripsAndOldMetadataUsesSafeDefaults() throws {
|
|
let semantic = AgentToolDescriptor.Presentation(
|
|
symbol: "magnifyingglass",
|
|
activity: .semanticArgument(key: "query", fallback: .web),
|
|
output: .json, presenterID: "builtin.web_search",
|
|
groupsWithAdjacentTools: false, actionKind: .search,
|
|
phaseKinds: ["running": .sshConnecting]
|
|
)
|
|
let encoded = try JSONEncoder().encode(semantic)
|
|
#expect(
|
|
try JSONDecoder().decode(AgentToolDescriptor.Presentation.self, from: encoded)
|
|
== semantic
|
|
)
|
|
|
|
let legacy = AgentToolDescriptor.Presentation(
|
|
symbol: "terminal", activity: .argument(key: "command", fallback: "a command")
|
|
)
|
|
var object = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(legacy)) as? [String: Any]
|
|
)
|
|
object["groupsWithAdjacentTools"] = nil
|
|
object["actionKind"] = nil
|
|
object["phaseKinds"] = nil
|
|
object["presenterID"] = nil
|
|
object["detailPresenterID"] = "builtin.web_search"
|
|
let decoded = try JSONDecoder().decode(
|
|
AgentToolDescriptor.Presentation.self,
|
|
from: JSONSerialization.data(withJSONObject: object)
|
|
)
|
|
#expect(decoded.groupsWithAdjacentTools)
|
|
#expect(decoded.actionKind == nil)
|
|
#expect(decoded.phaseKinds.isEmpty)
|
|
#expect(decoded.presenterID == "builtin.web_search")
|
|
}
|
|
|
|
@Test
|
|
func sshCommandSummaryUsesLocaleAppropriatePunctuationAcrossCommandPhases() {
|
|
let command = "printf hello\nworld"
|
|
let current = AgentToolDescriptor.Presentation(
|
|
symbol: "terminal",
|
|
activity: .semanticArgument(key: "command", fallback: .sshCommand),
|
|
output: .streams, actionKind: .run,
|
|
phaseKinds: [
|
|
"proposed": .sshPreparingCommand,
|
|
"waitingForApproval": .sshWaitingForCommand,
|
|
"running": .sshConnecting,
|
|
"command": .sshRunningCommand,
|
|
"completed": .sshCompletedCommand,
|
|
"denied": .sshDeniedCommand,
|
|
"failed": .sshFailedCommand,
|
|
"interrupted": .sshInterruptedCommand,
|
|
]
|
|
)
|
|
let legacy = AgentToolDescriptor.Presentation(
|
|
symbol: "terminal",
|
|
activity: .argument(key: "command", fallback: "the SSH command"),
|
|
output: .streams, actionKind: .run
|
|
)
|
|
let states: [(AgentToolCard.State, String?)] = [
|
|
(.proposed, nil), (.reviewing, nil), (.waitingForApproval, nil),
|
|
(.running, "command"), (.completed, nil), (.denied, nil),
|
|
(.failed, nil), (.interrupted, nil),
|
|
]
|
|
let locales: [(String, String?, String?)] = [
|
|
("en", nil, nil),
|
|
("zh-Hans", "“", "”"),
|
|
("zh-Hant", "「", "」"),
|
|
]
|
|
|
|
for presentation in [current, legacy] {
|
|
for (identifier, openingQuote, closingQuote) in locales {
|
|
for hostName in [String?.none, "Production"] {
|
|
for (state, phase) in states {
|
|
var card = makeCard(
|
|
name: "ssh_exec", target: .host,
|
|
arguments: ["command": .string(command)],
|
|
presentation: presentation
|
|
)
|
|
card.state = state
|
|
if let phase {
|
|
card.result = AgentToolResult(
|
|
callID: "call", content: "",
|
|
metadata: ["presentation_phase": .string(phase)]
|
|
)
|
|
}
|
|
|
|
let title = AgentToolPresentation.summary(
|
|
for: card, hostName: hostName,
|
|
locale: Locale(identifier: identifier)
|
|
)
|
|
let normalizedCommand = "printf hello world"
|
|
#expect(title.contains(normalizedCommand))
|
|
#expect(!title.contains("\n"))
|
|
if let openingQuote, let closingQuote {
|
|
#expect(title.contains("\(openingQuote)\(normalizedCommand)\(closingQuote)"))
|
|
} else {
|
|
#expect(!title.contains("“\(normalizedCommand)”"))
|
|
#expect(!title.contains("「\(normalizedCommand)」"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var example = makeCard(
|
|
name: "ssh_exec", target: .host,
|
|
arguments: ["command": .string(command)], presentation: current
|
|
)
|
|
example.state = .running
|
|
example.result = AgentToolResult(
|
|
callID: "call", content: "",
|
|
metadata: ["presentation_phase": .string("command")]
|
|
)
|
|
#expect(
|
|
AgentToolPresentation.summary(
|
|
for: example, hostName: "Production", locale: Locale(identifier: "en")
|
|
) == "Running printf hello world on Production"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func legacyCountedArrayActivityStillDecodes() throws {
|
|
let activity = AgentToolDescriptor.Presentation.Activity.arrayCount(
|
|
key: "questions", singular: "a question", plural: "questions",
|
|
fallback: "a question"
|
|
)
|
|
let data = try JSONEncoder().encode(activity)
|
|
#expect(try JSONDecoder().decode(type(of: activity), from: data) == activity)
|
|
}
|
|
|
|
private func makeCard(
|
|
name: String, target: AgentToolDescriptor.Target,
|
|
arguments: [String: AgentJSONValue] = [:],
|
|
presentation: AgentToolDescriptor.Presentation? = nil
|
|
) -> AgentToolCard {
|
|
AgentToolCard(
|
|
invocation: AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(id: "call", name: name, arguments: .object(arguments))
|
|
),
|
|
descriptor: AgentToolDescriptor(
|
|
name: name, summary: name,
|
|
inputSchema: .object(["type": .string("object")]),
|
|
target: target, approvalPolicy: .approve, presentation: presentation
|
|
)
|
|
)
|
|
}
|
|
|
|
private func hostJSON(
|
|
id: String, name: String, address: String, state: String, tags: [String],
|
|
groupID: String? = nil, temporary: Bool = false
|
|
) -> AgentJSONValue {
|
|
.object([
|
|
"id": .string(id), "display_name": .string(name), "address": .string(address),
|
|
"tags": .array(tags.map(AgentJSONValue.string)),
|
|
"connection_state": .string(state), "temporary": .bool(temporary),
|
|
"group_id": groupID.map(AgentJSONValue.string) ?? .null,
|
|
"group_path": .null,
|
|
])
|
|
}
|
|
}
|