From 711c8b2fce2cefcad5d71f62fcd5fce1ee5d8d29 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 29 Aug 2026 01:29:49 +0800 Subject: [PATCH] Add scratch content search tool --- ARCHITECTURE.md | 7 +- .../Application/Agent/AgentScratchTools.swift | 136 ++++++++++++++++- .../Agent/AgentScratchWorkspace.swift | 137 +++++++++++++++++ .../Application/Agent/AgentToolDetail.swift | 1 + Termind/Resources/Localizable.xcstrings | 74 +++++++++- TermindTests/AgentScratchTests.swift | 139 +++++++++++++++++- TermindTests/AgentToolPresentationTests.swift | 36 +++++ 7 files changed, 524 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f86a0791..0b9dea0e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -127,8 +127,11 @@ is why every scratch tool is parallel. `scratch_pull` and `scratch_push` move by between a host and the workspace without the bytes entering the transcript, so a remote file can be pulled, edited and pushed back without a copy of it crossing the model boundary in either direction; `scratch_edit` returns a unified diff rather than -the new file for the same reason. The SFTP tools are unchanged and know nothing about -the workspace. Because a push is approved by filename rather than by content, its +the new file for the same reason. `scratch_search` recursively searches UTF-8 content +inside that same scope and returns bounded matching-line previews, so locating a small +region does not require reading a large staged file into the transcript. The SFTP tools +are unchanged and know nothing about the workspace. Because a push is approved by +filename rather than by content, its preflight puts the size, digest and a redacted head into the approval reasons, and binds the approval to that digest through execution-only metadata so a file rewritten while the user was deciding is refused rather than written. diff --git a/Termind/Application/Agent/AgentScratchTools.swift b/Termind/Application/Agent/AgentScratchTools.swift index fb175ec3..6f1db56b 100644 --- a/Termind/Application/Agent/AgentScratchTools.swift +++ b/Termind/Application/Agent/AgentScratchTools.swift @@ -2,7 +2,7 @@ import Foundation /// The agent's own staging area, exposed as tools. /// -/// Six of these are `target: .local` and cannot leave `AgentScratchWorkspace`'s +/// Seven of these are `target: .local` and cannot leave `AgentScratchWorkspace`'s /// scope directory — not to a host, not to the network, not to any other part of /// the user's disk. That containment is what earns `.locallyContained` on the three /// that write: they change something, but only storage the app owns. @@ -42,7 +42,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { transfers = [] } return [ - list(workspace), read(workspace), write(workspace), + list(workspace), read(workspace), search(workspace), write(workspace), edit(workspace), diff(workspace), delete(workspace), ] + transfers + (web.map { [fetch(workspace, $0)] } ?? []) } @@ -129,6 +129,72 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding { } } + private static func search(_ workspace: AgentScratchWorkspace) -> AnyAgentTool { + AnyAgentTool(descriptor: descriptor( + "scratch_search", + """ + Search UTF-8 files in the scratch workspace one line at a time. Omit path to \ + search the whole workspace, or pass a directory for a recursive search or one \ + file to search only it. Literal matching is the default; set regex for a regular \ + expression. Matching is smart-case unless case_sensitive is supplied: a query \ + containing an uppercase letter is case-sensitive, and other queries are not. \ + Binary files are skipped. Results contain bounded matching-line previews; use \ + scratch_read around a returned line number for more context. + """, + properties: [ + "query": string(max: AgentScratchWorkspace.Limits.searchQueryCharacters), + "path": string(max: pathBytes), "regex": boolean(), + "case_sensitive": boolean(), + "limit": integer(min: 1, max: AgentScratchWorkspace.Limits.searchResults), + ], + required: ["query"], target: .local, safety: .locallyReadOnly, + concurrency: .parallel, + presentation: .init( + symbol: "magnifyingglass", + activity: .semanticArgument(key: "query", fallback: .scratchWorkspace), + output: .json, detailPresenterID: "builtin.scratch_search", + actionKind: .search + ) + )) { invocation, _ in + let arguments = try Arguments(invocation) + let outcome = try await workspace.search( + arguments.string("query"), path: arguments.optionalString("path"), + regex: arguments.optionalBool("regex") ?? false, + caseSensitive: arguments.optionalBool("case_sensitive"), + limit: arguments.optionalInt("limit") ?? 20 + ) + var returned = outcome.matches + var truncated = outcome.isTruncated + var payload = searchPayload(outcome, matches: returned, truncated: truncated) + while payload.encodedString.utf8.count > contentBytes, !returned.isEmpty { + returned.removeLast() + truncated = true + payload = searchPayload(outcome, matches: returned, truncated: truncated) + } + return result(invocation, payload, truncated: truncated) + } + } + + private static func searchPayload( + _ outcome: ScratchSearchOutcome, matches: [ScratchSearchMatch], truncated: Bool + ) -> AgentJSONValue { + .object([ + "matches": .array(matches.map { match in + .object([ + "path": .string(match.path), "line": .number(Double(match.line)), + "text": .string(match.text), + "text_truncated": .bool(match.textTruncated), + ]) + }), + "matching_lines": .number(Double(outcome.matchingLines)), + "returned_matches": .number(Double(matches.count)), + "searched_files": .number(Double(outcome.searchedFiles)), + "skipped_binary_files": .number(Double(outcome.skippedBinaryFiles)), + "case_sensitive": .bool(outcome.caseSensitive), + "truncated": .bool(truncated), + ]) + } + private static func write(_ workspace: AgentScratchWorkspace) -> AnyAgentTool { AnyAgentTool(descriptor: descriptor( "scratch_write", @@ -577,6 +643,9 @@ nonisolated extension AgentScratchTools { static let readDetailPresenter = AgentToolDetailPresenter( id: "builtin.scratch_read", present: presentReadDetail ) + static let searchDetailPresenter = AgentToolDetailPresenter( + id: "builtin.scratch_search", present: presentSearchDetail + ) static let writeDetailPresenter = AgentToolDetailPresenter( id: "builtin.scratch_write", present: presentWriteDetail ) @@ -668,6 +737,69 @@ nonisolated extension AgentScratchTools { return items } + private static func presentSearchDetail( + _ input: AgentToolDetailInput + ) -> [AgentToolDetail.Item] { + typealias F = AgentToolDetailFormatting + guard let object = input.result.objectValue else { + return F.genericItems(input.result, locale: input.locale) + } + let matching = object["matching_lines"]?.integerValue ?? 0 + let returned = object["returned_matches"]?.integerValue ?? 0 + let searched = object["searched_files"]?.integerValue ?? 0 + let skipped = object["skipped_binary_files"]?.integerValue ?? 0 + var items: [AgentToolDetail.Item] = [ + .message( + String( + localized: "\(matching) matching lines in \(searched) files", + locale: input.locale + ), + matching == 0 ? .secondary : .success + ) + ] + let rows = (object["matches"]?.arrayValue ?? []).compactMap { + match -> AgentToolDetail.ListRow? in + guard let fields = match.objectValue, + let path = fields["path"]?.stringValue, + let line = fields["line"]?.integerValue + else { return nil } + return .init( + title: "\(path):\(line)", subtitle: fields["text"]?.stringValue, + badges: fields["text_truncated"]?.boolValue == true + ? [F.localized("Truncated", locale: input.locale)] : [], + symbol: "doc.text" + ) + } + items.append(.list(.init( + title: nil, rows: rows, + emptyMessage: F.localized("No matches", locale: input.locale) + ))) + if object["truncated"]?.boolValue == true { + items.append(.message( + String( + localized: "Showing \(returned) of \(matching) matching lines", + locale: input.locale + ), + .warning + )) + } + if skipped > 0 { + items.append(.message( + String(localized: "Skipped \(skipped) binary files", locale: input.locale), + .secondary + )) + } + items.append(F.field( + "Case sensitive", + F.localized( + object["case_sensitive"]?.boolValue == true ? "Yes" : "No", + locale: input.locale + ), + locale: input.locale + )) + return items + } + private static func presentWriteDetail( _ input: AgentToolDetailInput ) -> [AgentToolDetail.Item] { diff --git a/Termind/Application/Agent/AgentScratchWorkspace.swift b/Termind/Application/Agent/AgentScratchWorkspace.swift index a24d2a0d..fa312bf0 100644 --- a/Termind/Application/Agent/AgentScratchWorkspace.swift +++ b/Termind/Application/Agent/AgentScratchWorkspace.swift @@ -45,6 +45,24 @@ nonisolated struct ScratchText: Hashable, Sendable { var lineEnding: LineEnding } +nonisolated struct ScratchSearchMatch: Hashable, Sendable { + var path: String + /// 1-based, matching `scratch_read`, diffs and editors. + var line: Int + var text: String + var textTruncated: Bool +} + +nonisolated struct ScratchSearchOutcome: Hashable, Sendable { + var matches: [ScratchSearchMatch] + /// Matching lines in the whole search, including those past `limit`. + var matchingLines: Int + var searchedFiles: Int + var skippedBinaryFiles: Int + var caseSensitive: Bool + var isTruncated: Bool +} + /// One exact-text replacement, matched against the file as it was before any edit in /// the same call was applied. nonisolated struct ScratchEdit: Hashable, Sendable { @@ -101,6 +119,9 @@ actor AgentScratchWorkspace { /// `sftp_read`'s inline ceiling. A *file* may be far larger; reading it whole /// is what the offset/limit window and `scratch_push` are for. static let inlineReadBytes = 128 * 1_024 + static let searchQueryCharacters = 4_096 + static let searchLineBytes = 1_024 + static let searchResults = 100 static let edits = 32 } @@ -206,6 +227,88 @@ actor AgentScratchWorkspace { return ScratchFile(path: path, data: data, sha256: Self.digest(data)) } + /// Search UTF-8 files one line at a time without putting whole files into the + /// transcript. A directory scope is always recursive; `nil` means the workspace + /// root. Non-text files are counted and skipped rather than making a mixed tree + /// impossible to search. + func search( + _ query: String, path: String? = nil, regex: Bool = false, + caseSensitive requestedCaseSensitivity: Bool? = nil, limit: Int = 20 + ) throws -> ScratchSearchOutcome { + guard !query.isEmpty else { + throw AgentToolError.invalidArguments("query is required.") + } + guard query.count <= Limits.searchQueryCharacters else { + throw AgentToolError.invalidArguments( + "query is past the limit of \(Limits.searchQueryCharacters) characters." + ) + } + guard !query.contains("\n"), !query.contains("\r") else { + throw AgentToolError.invalidArguments( + "query must fit on one line; scratch_search does not match across lines." + ) + } + guard (1...Limits.searchResults).contains(limit) else { + throw AgentToolError.invalidArguments( + "limit must be between 1 and \(Limits.searchResults)." + ) + } + + let caseSensitive = requestedCaseSensitivity ?? query.unicodeScalars.contains { + CharacterSet.uppercaseLetters.contains($0) + } + let pattern = regex ? query : NSRegularExpression.escapedPattern(for: query) + let expression: NSRegularExpression + do { + expression = try NSRegularExpression( + pattern: pattern, options: caseSensitive ? [] : [.caseInsensitive] + ) + } catch { + throw AgentToolError.invalidArguments("query is not a valid regular expression.") + } + + let root = try root() + let files = try searchFiles(at: path, in: root) + var matches: [ScratchSearchMatch] = [] + var matchingLines = 0 + var searchedFiles = 0 + var skippedBinaryFiles = 0 + var previewWasTruncated = false + + for filePath in files { + let file = try data(at: filePath) + guard let raw = String(data: file.data, encoding: .utf8) else { + skippedBinaryFiles += 1 + continue + } + searchedFiles += 1 + let normalized = LineEnding.normalizedToLF(raw) + let (lines, _) = UnifiedDiff.split(normalized) + for (index, line) in lines.enumerated() { + let wholeLine = NSRange(line.startIndex.. matches.count + ) + } + // MARK: Writing @discardableResult @@ -505,6 +608,40 @@ actor AgentScratchWorkspace { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } + private func searchFiles(at path: String?, in root: URL) throws -> [String] { + guard let path else { + var entries: [ScratchEntry] = [] + try walk(root, root: root, recursive: true) { entries.append($0) } + return entries.filter { $0.kind == .file }.map(\.path).sorted() + } + let url = try resolve(path, in: root) + guard let values = try? url.resourceValues( + forKeys: [.isDirectoryKey, .isRegularFileKey] + ) else { throw AgentToolError.scratchNotFound(path) } + if values.isRegularFile == true { return [Self.relative(url, to: root)] } + guard values.isDirectory == true else { throw AgentToolError.scratchNotFound(path) } + var entries: [ScratchEntry] = [] + try walk(url, root: root, recursive: true) { entries.append($0) } + return entries.filter { $0.kind == .file }.map(\.path).sorted() + } + + /// Keep the first match visible in a bounded UTF-8 window. The byte boundaries + /// are advanced to scalar boundaries so clipping never introduces replacement + /// characters into a valid text file. + private static func searchPreview( + of line: String, around match: Range, maximumBytes: Int + ) -> (text: String, truncated: Bool) { + let bytes = Array(line.utf8) + guard bytes.count > maximumBytes else { return (line, false) } + let matchOffset = line[.. 0 ? "…" : "") + body + (end < bytes.count ? "…" : ""), true) + } + // MARK: - Matching /// Finds the one region `needle` names, or explains why it named none or several. diff --git a/Termind/Application/Agent/AgentToolDetail.swift b/Termind/Application/Agent/AgentToolDetail.swift index 94d2e8ae..37043f14 100644 --- a/Termind/Application/Agent/AgentToolDetail.swift +++ b/Termind/Application/Agent/AgentToolDetail.swift @@ -110,6 +110,7 @@ nonisolated enum AgentBuiltInToolDetailPresenters { AgentBuiltInTools.requestUserSecretDetailPresenter, AgentScratchTools.listDetailPresenter, AgentScratchTools.readDetailPresenter, + AgentScratchTools.searchDetailPresenter, AgentScratchTools.writeDetailPresenter, AgentScratchTools.editDetailPresenter, AgentScratchTools.diffDetailPresenter, diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 8cbc863d..dbd3f541 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -531,6 +531,24 @@ } } }, + "%lld matching lines in %lld files" : { + "comment" : "A message that shows the number of matching lines and the number of files that were searched.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld matching lines in %2$lld files" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在%2$lld个文件中找到%1$lld个匹配行" + } + } + } + }, "%lld Models Offered" : { "localizations" : { "zh-Hans" : { @@ -2105,6 +2123,18 @@ } } }, + "Case sensitive" : { + "comment" : "Label for a checkbox indicating whether the search is case sensitive.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "区分大小写" + } + } + } + }, "Certificate" : { "localizations" : { "zh-Hans" : { @@ -6793,6 +6823,18 @@ } } }, + "No matches" : { + "comment" : "Text displayed when there are no search results.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无匹配项" + } + } + } + }, "No Matching Hosts" : { "localizations" : { "zh-Hans" : { @@ -10029,6 +10071,24 @@ } } }, + "Showing %lld of %lld matching lines" : { + "comment" : "A warning message that indicates that the search result count is not the same as the matching count.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Showing %1$lld of %2$lld matching lines" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在显示%1$lld/%2$lld个匹配行" + } + } + } + }, "Sidebar" : { "localizations" : { "zh-Hans" : { @@ -10129,6 +10189,18 @@ } } }, + "Skipped %lld binary files" : { + "comment" : "A description of the number of binary files that were skipped during a search.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已跳过%lld个二进制文件" + } + } + } + }, "Snippet Name" : { "comment" : "Label for the text field used to name a new snippet.", "isCommentAutoGenerated" : true, @@ -13573,4 +13645,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/TermindTests/AgentScratchTests.swift b/TermindTests/AgentScratchTests.swift index 819a65a1..403c358d 100644 --- a/TermindTests/AgentScratchTests.swift +++ b/TermindTests/AgentScratchTests.swift @@ -53,6 +53,9 @@ struct AgentScratchTests { // And it is not reported as content either, so nothing can act on it. let listed = try await workspace.list(nil, recursive: true).map(\.path) #expect(listed == ["anchor.txt"]) + let searched = try await workspace.search("anchor") + #expect(searched.matches.map(\.path) == ["anchor.txt"]) + #expect(searched.searchedFiles == 1) } @Test @@ -148,6 +151,108 @@ struct AgentScratchTests { #expect(try await workspace.data(at: "blob.bin").data == binary) } + // MARK: - Searching + + @Test + func searchSupportsLiteralRegexAndSmartCaseMatching() async throws { + let workspace = Fixture.workspace() + _ = try await workspace.write( + "a.txt", data: Data("Alpha alpha\nneedle twice needle\nserver_42\n".utf8) + ) + _ = try await workspace.write( + "b.txt", data: Data("alpha\nserver-x\n".utf8) + ) + + let insensitive = try await workspace.search("alpha") + #expect(!insensitive.caseSensitive) + #expect(insensitive.matchingLines == 2) + #expect(insensitive.matches.map { "\($0.path):\($0.line)" } == ["a.txt:1", "b.txt:1"]) + + let smartCase = try await workspace.search("Alpha") + #expect(smartCase.caseSensitive) + #expect(smartCase.matches.map(\.path) == ["a.txt"]) + + let overridden = try await workspace.search("Alpha", caseSensitive: false) + #expect(!overridden.caseSensitive) + #expect(overridden.matchingLines == 2) + + let regex = try await workspace.search(#"server_[0-9]+"#, regex: true) + #expect(regex.matches.map { "\($0.path):\($0.line)" } == ["a.txt:3"]) + + // Search results are matching lines, not occurrences within those lines. + let duplicate = try await workspace.search("needle") + #expect(duplicate.matchingLines == 1) + #expect(duplicate.matches.count == 1) + } + + @Test + func searchScopesAndOrdersFilesAndSkipsBinaryContent() async throws { + let workspace = Fixture.workspace() + _ = try await workspace.write("z.txt", data: Data("hit at root\n".utf8)) + _ = try await workspace.write("dir/b.txt", data: Data("before\r\nhit b\r\n".utf8)) + _ = try await workspace.write("dir/a.txt", data: Data("hit a\n".utf8)) + _ = try await workspace.write(".hidden", data: Data("hidden hit\n".utf8)) + _ = try await workspace.write("dir/blob.bin", data: Data([0xFF, 0x00, 0x80])) + + let directory = try await workspace.search("hit", path: "dir") + #expect(directory.matches.map { "\($0.path):\($0.line)" } == [ + "dir/a.txt:1", "dir/b.txt:2", + ]) + #expect(directory.searchedFiles == 2) + #expect(directory.skippedBinaryFiles == 1) + + let file = try await workspace.search("hit", path: "z.txt") + #expect(file.matches.map(\.path) == ["z.txt"]) + #expect(file.searchedFiles == 1) + + let wholeWorkspace = try await workspace.search("hit") + #expect(wholeWorkspace.matches.map(\.path) == [ + ".hidden", "dir/a.txt", "dir/b.txt", "z.txt", + ]) + #expect(wholeWorkspace.searchedFiles == 4) + #expect(wholeWorkspace.skippedBinaryFiles == 1) + } + + @Test + func searchBoundsResultsAndKeepsTheMatchVisibleInLongLines() async throws { + let workspace = Fixture.workspace() + let longLine = String(repeating: "x", count: 5_000) + + "target" + String(repeating: "y", count: 5_000) + _ = try await workspace.write( + "long.txt", data: Data((longLine + "\ntarget two\ntarget three\n").utf8) + ) + + let outcome = try await workspace.search("target", limit: 1) + #expect(outcome.matchingLines == 3) + #expect(outcome.matches.count == 1) + #expect(outcome.matches[0].text.contains("target")) + #expect(outcome.matches[0].textTruncated) + #expect(outcome.isTruncated) + } + + @Test + func searchRejectsInvalidPatternsPathsAndBounds() async throws { + let workspace = Fixture.workspace() + _ = try await workspace.write("a.txt", data: Data("text\n".utf8)) + for query in ["", "two\nlines"] { + await #expect(throws: (any Error).self) { + try await workspace.search(query) + } + } + await #expect(throws: (any Error).self) { + try await workspace.search("[", regex: true) + } + await #expect(throws: (any Error).self) { + try await workspace.search("text", path: "../outside") + } + await #expect(throws: (any Error).self) { + try await workspace.search("text", path: "missing") + } + await #expect(throws: (any Error).self) { + try await workspace.search("text", limit: 101) + } + } + // MARK: - Editing @Test @@ -420,7 +525,7 @@ struct AgentScratchTests { let local = AgentScratchTools.make(workspace: workspace) #expect(local.map(\.descriptor.name).sorted() == [ "scratch_delete", "scratch_diff", "scratch_edit", - "scratch_list", "scratch_read", "scratch_write", + "scratch_list", "scratch_read", "scratch_search", "scratch_write", ]) // Every purely local tool is parallel, and only the three that change // something claim to. @@ -432,6 +537,12 @@ struct AgentScratchTests { == ["scratch_delete", "scratch_edit", "scratch_write"] ) #expect(!local.contains { $0.descriptor.safety == .requiresAuthorization }) + let search = try #require(local.first { $0.descriptor.name == "scratch_search" }) + let properties = search.descriptor.inputSchema.objectValue?["properties"]?.objectValue + #expect(properties?.keys.sorted() == [ + "case_sensitive", "limit", "path", "query", "regex", + ]) + #expect(search.descriptor.presentation?.actionKind == .search) // Transfer is absent until a connection pool is supplied, on the same terms // as `web_search`: a tool that cannot work is not offered. @@ -510,6 +621,32 @@ struct AgentScratchTests { ) } + @Test + func searchToolBoundsItsEncodedTranscriptResult() async throws { + let workspace = Fixture.workspace() + let line = String(repeating: "\u{0001}", count: 1_100) + "target" + let body = Array(repeating: line, count: 100).joined(separator: "\n") + _ = try await workspace.write("large.txt", data: Data(body.utf8)) + let tool = try #require( + AgentScratchTools.make(workspace: workspace) + .first { $0.descriptor.name == "scratch_search" } + ) + let result = try await tool.execute( + Fixture.invocation("scratch_search", [ + "query": .string("target"), "limit": .number(100), + ]), context: Fixture.context() + ) + let payload = try #require(result.untrustedPayload) + let object = try #require( + try AgentJSONValue.decode(Data(payload.utf8)).objectValue + ) + #expect(payload.utf8.count <= 128 * 1_024) + #expect(result.isTruncated) + #expect(object["matching_lines"]?.integerValue == 100) + #expect((object["returned_matches"]?.integerValue ?? 100) < 100) + #expect(object["truncated"]?.boolValue == true) + } + // MARK: - Transfer @Test diff --git a/TermindTests/AgentToolPresentationTests.swift b/TermindTests/AgentToolPresentationTests.swift index 0b8542d0..787d3242 100644 --- a/TermindTests/AgentToolPresentationTests.swift +++ b/TermindTests/AgentToolPresentationTests.swift @@ -663,6 +663,42 @@ struct AgentToolPresentationTests { #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, detailPresenterID: "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)) + 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 finishedBatchSummaryCountsRepeatedActivitiesAndPreservesFailures() { let questionPresentation = AgentBuiltInTools.requestUserInput().descriptor.presentation