520 lines
21 KiB
Swift
520 lines
21 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
struct WebSearchTests {
|
|
|
|
// MARK: - Configuration
|
|
|
|
@Test
|
|
func keylessBackendIsConfiguredWithoutAKey() {
|
|
let bing = WebSearchClient(backend: .bing)
|
|
#expect(bing.isConfigured)
|
|
#expect(!SearchBackend.bing.requiresAPIKey)
|
|
|
|
let brave = WebSearchClient(backend: .brave)
|
|
#expect(!brave.isConfigured)
|
|
#expect(WebSearchClient(backend: .brave, apiKey: "token").isConfigured)
|
|
}
|
|
|
|
@Test
|
|
func blankBaseURLResolvesToTheBackendDefault() {
|
|
#expect(WebSearchClient(backend: .bing).resolvedURL == "https://www.bing.com/search")
|
|
#expect(
|
|
WebSearchClient(backend: .brave, apiKey: "k").resolvedURL
|
|
== "https://api.search.brave.com/res/v1/web/search"
|
|
)
|
|
// A pasted URL very often carries a trailing slash, and every path
|
|
// starts with one.
|
|
#expect(
|
|
WebSearchClient(backend: .tavily, baseURL: "https://proxy.example/", apiKey: "k")
|
|
.resolvedURL == "https://proxy.example/search"
|
|
)
|
|
}
|
|
|
|
// MARK: - Requests
|
|
|
|
@Test
|
|
func bingRequestLooksLikeSafariOnAMac() throws {
|
|
let request = try WebSearchClient(backend: .bing, market: "en-US")
|
|
.buildRequest(query: "swift concurrency", count: 5)
|
|
|
|
let url = try #require(request.url?.absoluteString)
|
|
#expect(url.hasPrefix("https://www.bing.com/search?"))
|
|
#expect(url.contains("q=swift%20concurrency") || url.contains("q=swift+concurrency"))
|
|
#expect(request.httpMethod == "GET")
|
|
|
|
let agent = try #require(request.value(forHTTPHeaderField: "User-Agent"))
|
|
#expect(agent.contains("Macintosh"))
|
|
#expect(agent.contains("Safari"))
|
|
#expect(!agent.contains("Windows"))
|
|
#expect(request.value(forHTTPHeaderField: "Accept-Language") != nil)
|
|
}
|
|
|
|
/// Verified against the live endpoint: with no `mkt`, Bing answers an
|
|
/// anonymous request with a well-formed page of results for an unrelated
|
|
/// query. The parse succeeds and the model is handed confident nonsense,
|
|
/// so this parameter is load-bearing rather than a locale nicety.
|
|
@Test
|
|
func bingAlwaysCarriesAMarket() throws {
|
|
let url = try #require(
|
|
WebSearchClient(backend: .bing, market: "ja-JP")
|
|
.buildRequest(query: "swift", count: 5).url?.absoluteString
|
|
)
|
|
#expect(url.contains("mkt=ja-JP"))
|
|
|
|
// Derived per component, so a Mac with a language but no region still
|
|
// searches in its own language rather than falling all the way to en-US.
|
|
let market = WebSearchClient.preferredMarket
|
|
#expect(market.contains("-"))
|
|
#expect(market.split(separator: "-").count == 2)
|
|
}
|
|
|
|
@Test
|
|
func keyedBackendsCarryTheirOwnAuthenticationHeader() throws {
|
|
let brave = try WebSearchClient(backend: .brave, apiKey: "brave-token")
|
|
.buildRequest(query: "q", count: 3)
|
|
#expect(brave.value(forHTTPHeaderField: "X-Subscription-Token") == "brave-token")
|
|
|
|
let tavily = try WebSearchClient(backend: .tavily, apiKey: "tavily-token")
|
|
.buildRequest(query: "q", count: 3)
|
|
#expect(tavily.value(forHTTPHeaderField: "Authorization") == "Bearer tavily-token")
|
|
#expect(tavily.httpMethod == "POST")
|
|
|
|
let exa = try WebSearchClient(backend: .exa, apiKey: "exa-token")
|
|
.buildRequest(query: "q", count: 3)
|
|
#expect(exa.value(forHTTPHeaderField: "x-api-key") == "exa-token")
|
|
}
|
|
|
|
@Test
|
|
func exaAsksForCappedSnippetsRatherThanWholePages() throws {
|
|
let request = try WebSearchClient(backend: .exa, apiKey: "k")
|
|
.buildRequest(query: "q", count: 3)
|
|
let payload = try #require(request.httpBody)
|
|
let body = try #require(
|
|
JSONSerialization.jsonObject(with: payload) as? [String: Any]
|
|
)
|
|
let contents = try #require(body["contents"] as? [String: Any])
|
|
let text = try #require(contents["text"] as? [String: Any])
|
|
// Reading a page in full stays `fetch`'s job, because that is the path
|
|
// with address classification on the target.
|
|
#expect(text["maxCharacters"] as? Int == 600)
|
|
}
|
|
|
|
// MARK: - Bing result page
|
|
|
|
/// Trimmed from a real results page: the `b_algo` list item, the `h2 > a`
|
|
/// title link, and the `b_algoSlug` snippet Kelivo's selectors read.
|
|
private static let bingPage = """
|
|
<html><body><ol id="b_results">
|
|
<li class="b_algo b_algoBorder" data-bm="5">
|
|
<h2><a href="https://swift.org/documentation/" h="ID=SERP,5">Swift Documentation</a></h2>
|
|
<div class="b_caption">
|
|
<p class="b_algoSlug">The <strong>Swift</strong> language guide & reference.</p>
|
|
</div>
|
|
</li>
|
|
<li class="b_algo">
|
|
<h2><a href="https://www.bing.com/ck/a?!&&p=1&u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9wYWdl">Example Page</a></h2>
|
|
<div class="b_caption"><p>A second result.</p></div>
|
|
</li>
|
|
<li class="b_algo">
|
|
<h2><a href="https://third.example/">Third</a></h2>
|
|
</li>
|
|
</ol></body></html>
|
|
"""
|
|
|
|
@Test
|
|
func bingPageYieldsTitleURLAndSnippet() {
|
|
let results = WebSearchClient.bingResults(Self.bingPage, limit: 10)
|
|
|
|
#expect(results.count == 3)
|
|
#expect(results[0].title == "Swift Documentation")
|
|
#expect(results[0].url == "https://swift.org/documentation/")
|
|
// Tags stripped and entities decoded: the model reads this as prose,
|
|
// and markup here costs tokens and invites confusion with instructions.
|
|
#expect(results[0].snippet == "The Swift language guide & reference.")
|
|
// A result with no snippet is still a result.
|
|
#expect(results[2].title == "Third")
|
|
#expect(results[2].snippet.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func bingRedirectWrapperIsUnwrappedToTheRealURL() {
|
|
let results = WebSearchClient.bingResults(Self.bingPage, limit: 10)
|
|
// Left wrapped, the model would be handed a bing.com/ck/a URL that says
|
|
// nothing about the source it is about to trust.
|
|
#expect(results[1].url == "https://example.com/page")
|
|
#expect(results[1].title == "Example Page")
|
|
|
|
// Anything that is not the `u=a1<base64url>` form is left alone.
|
|
let plain = "https://example.com/ck/a?u=not-base64"
|
|
#expect(WebSearchClient.unwrapBingRedirect(plain) == plain)
|
|
}
|
|
|
|
@Test
|
|
func bingResultsRespectTheRequestedCount() {
|
|
#expect(WebSearchClient.bingResults(Self.bingPage, limit: 2).count == 2)
|
|
}
|
|
|
|
/// Bing writes its date separator as `· `, so a fixed list of
|
|
/// named entities leaves raw numeric references in the text the model reads.
|
|
@Test
|
|
func numericEntitiesAreDecoded() {
|
|
#expect(
|
|
WebSearchClient.plainText("Jul 10, 2026· At Swift, we're open")
|
|
== "Jul 10, 2026·\u{20}At Swift, we're open"
|
|
)
|
|
#expect(WebSearchClient.plainText("café & bar") == "café & bar")
|
|
// A stray `&#` is left alone rather than swallowing the rest of the
|
|
// text up to whatever semicolon comes next.
|
|
#expect(WebSearchClient.plainText("A &# B; C") == "A &# B; C")
|
|
}
|
|
|
|
@Test
|
|
func markupThatMovedYieldsNoResultsRatherThanFailing() {
|
|
// The honest failure mode of scraping, and the reason a keyed backend
|
|
// exists: an empty list reads to the model as "nothing found".
|
|
#expect(WebSearchClient.bingResults("<html><body>no results here</body></html>", limit: 5).isEmpty)
|
|
}
|
|
|
|
// MARK: - DuckDuckGo result page
|
|
|
|
private static let duckDuckGoPage = """
|
|
<html><body>
|
|
<div class="result results_links results_links_deep web-result ">
|
|
<div class="result__body">
|
|
<h2 class="result__title">
|
|
<a class="result__a" href="https://nvd.nist.gov/vuln/detail/CVE-2024-3094">NVD - CVE-2024-3094</a>
|
|
</h2>
|
|
<a class="result__snippet" href="#">Malicious code was discovered in <b>xz</b> tarballs.</a>
|
|
</div>
|
|
</div>
|
|
<div class="result results_links result--ad">
|
|
<div class="result__body">
|
|
<h2><a class="result__a" href="https://sponsor.example/buy">Sponsored Thing</a></h2>
|
|
<a class="result__snippet" href="#">An advert.</a>
|
|
</div>
|
|
</div>
|
|
<div class="result results_links results_links_deep web-result ">
|
|
<div class="result__body">
|
|
<h2><a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FXZ&rut=abc">XZ Utils backdoor</a></h2>
|
|
</div>
|
|
</div>
|
|
</body></html>
|
|
"""
|
|
|
|
@Test
|
|
func duckDuckGoPageYieldsResultsAndSkipsAds() {
|
|
let results = WebSearchClient.duckDuckGoResults(Self.duckDuckGoPage, limit: 10)
|
|
|
|
#expect(results.count == 2)
|
|
#expect(results[0].url == "https://nvd.nist.gov/vuln/detail/CVE-2024-3094")
|
|
#expect(results[0].title == "NVD - CVE-2024-3094")
|
|
#expect(results[0].snippet == "Malicious code was discovered in xz tarballs.")
|
|
// An advert wears the same result markup, and handing one to the model
|
|
// as a source is worse than returning one result fewer.
|
|
#expect(!results.contains { $0.url.contains("sponsor.example") })
|
|
}
|
|
|
|
@Test
|
|
func duckDuckGoRedirectWrapperIsUnwrapped() {
|
|
let results = WebSearchClient.duckDuckGoResults(Self.duckDuckGoPage, limit: 10)
|
|
#expect(results[1].url == "https://en.wikipedia.org/wiki/XZ")
|
|
|
|
// Both shapes appear on the same endpoint, so a direct link must survive
|
|
// the unwrapper untouched.
|
|
let direct = "https://example.com/page"
|
|
#expect(WebSearchClient.unwrapDuckDuckGoRedirect(direct) == direct)
|
|
}
|
|
|
|
@Test
|
|
func duckDuckGoIsKeylessAndPostsItsQuery() throws {
|
|
#expect(!SearchBackend.duckDuckGo.requiresAPIKey)
|
|
let request = try WebSearchClient(backend: .duckDuckGo, market: "en-US")
|
|
.buildRequest(query: "swift 6", count: 5)
|
|
|
|
#expect(request.httpMethod == "POST")
|
|
#expect(request.url?.absoluteString == "https://html.duckduckgo.com/html/")
|
|
let body = String(decoding: try #require(request.httpBody), as: UTF8.self)
|
|
#expect(body.contains("q=swift"))
|
|
// DuckDuckGo's region is Bing's market with its halves swapped.
|
|
#expect(body.contains("kl=us-en"))
|
|
}
|
|
|
|
// MARK: - JSON backends
|
|
|
|
@Test
|
|
func braveResponseDecodesIntoResults() throws {
|
|
let payload = Data("""
|
|
{"web": {"results": [
|
|
{"title": "Brave <strong>Hit</strong>", "url": "https://a.example",
|
|
"description": "First & best", "page_age": "2026-01-02T03:04:05Z"},
|
|
{"title": "No URL"}
|
|
]}}
|
|
""".utf8)
|
|
|
|
let results = try WebSearchClient.decode(payload, backend: .brave, limit: 10)
|
|
|
|
// The row with no URL is dropped rather than thrown on: one malformed
|
|
// result should not lose the others.
|
|
#expect(results.count == 1)
|
|
#expect(results[0].title == "Brave Hit")
|
|
#expect(results[0].snippet == "First & best")
|
|
#expect(results[0].publishedAt != nil)
|
|
}
|
|
|
|
@Test
|
|
func tavilyAndExaReadTheirOwnSnippetFields() throws {
|
|
let tavily = try WebSearchClient.decode(
|
|
Data("""
|
|
{"results": [{"title": "T", "url": "https://t.example",
|
|
"content": "tavily snippet", "published_date": "2026-02-03"}]}
|
|
""".utf8),
|
|
backend: .tavily, limit: 10
|
|
)
|
|
#expect(tavily[0].snippet == "tavily snippet")
|
|
#expect(tavily[0].publishedAt != nil)
|
|
|
|
let exa = try WebSearchClient.decode(
|
|
Data("""
|
|
{"results": [{"title": "E", "url": "https://e.example", "text": "exa snippet"}]}
|
|
""".utf8),
|
|
backend: .exa, limit: 10
|
|
)
|
|
#expect(exa[0].snippet == "exa snippet")
|
|
}
|
|
|
|
@Test
|
|
func nonJSONFromAKeyedBackendIsAnError() {
|
|
#expect(throws: WebSearchError.self) {
|
|
try WebSearchClient.decode(Data("<html>nope</html>".utf8), backend: .brave, limit: 5)
|
|
}
|
|
}
|
|
|
|
// MARK: - The tool
|
|
|
|
@Test
|
|
func webSearchToolIsReadOnlyForAPublicBackend() async throws {
|
|
let client = WebSearchClient(backend: .bing) { _ in .publicInternet }
|
|
let tool = AgentBuiltInTools.webSearch(client)
|
|
|
|
#expect(tool.descriptor.name == "web_search")
|
|
#expect(tool.descriptor.target == .network)
|
|
#expect(tool.descriptor.safety == .locallyReadOnly)
|
|
|
|
let preflight = try await tool.preflight(invocation(query: "swift"))
|
|
#expect(preflight.safety == .locallyReadOnly)
|
|
#expect(preflight.reasons.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func aBackendOnAPrivateNetworkAsksFirst() async throws {
|
|
// Matching `fetch`: a search backend configured at a LAN address is
|
|
// disclosed rather than silently reached, even though a query changes
|
|
// nothing.
|
|
let client = WebSearchClient(
|
|
backend: .tavily, baseURL: "https://searx.lan", apiKey: "k"
|
|
) { _ in .restricted }
|
|
let preflight = try await AgentBuiltInTools.webSearch(client)
|
|
.preflight(invocation(query: "swift"))
|
|
|
|
#expect(preflight.safety == .requiresAuthorization)
|
|
#expect(preflight.reasons.count == 1)
|
|
#expect(preflight.reasons[0].contains("https://searx.lan/search"))
|
|
}
|
|
|
|
@Test
|
|
func aQueryIsRequired() async {
|
|
let client = WebSearchClient(backend: .bing) { _ in .publicInternet }
|
|
await #expect(throws: AgentToolError.self) {
|
|
try await AgentBuiltInTools.webSearch(client).preflight(
|
|
AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(id: "1", name: "web_search", arguments: .object([:]))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor
|
|
func theToolIsAbsentUntilABackendIsConfigured() {
|
|
let hosts = AgentHostResolver(store: HostLibraryModel())
|
|
let pool = AgentConnectionPool(
|
|
configuration: { _ in throw AgentToolError.invalidHost },
|
|
routeFingerprint: { _ in "" },
|
|
openExec: { _ in throw AgentToolError.invalidHost },
|
|
openSFTP: { _ in throw AgentToolError.invalidHost },
|
|
sweepInterval: nil
|
|
)
|
|
|
|
let without = AgentBuiltInTools.make(hosts: hosts, connections: pool)
|
|
#expect(!without.contains { $0.descriptor.name == "web_search" })
|
|
|
|
// A model shown a tool it cannot use spends a turn discovering that.
|
|
let with = AgentBuiltInTools.make(
|
|
hosts: hosts, connections: pool, search: WebSearchClient(backend: .bing)
|
|
)
|
|
#expect(with.contains { $0.descriptor.name == "web_search" })
|
|
}
|
|
|
|
// MARK: - The built-in switch
|
|
|
|
/// The escape hatch, and the one thing it must not do is leave a model with
|
|
/// no search at all: turning built-in off has to hand the work to the
|
|
/// backend, not drop it.
|
|
@Test
|
|
func turningOffBuiltInSendsEverythingThroughTheBackend() {
|
|
var model = AIModel(id: "gpt-5.4")
|
|
model.abilities = [.toolCall, .webSearch]
|
|
var provider = ModelProvider(name: "OpenAI")
|
|
provider.usesResponsesAPI = true
|
|
|
|
// With it on, this pairing is the one case that goes native.
|
|
#expect(provider.supportsNativeWebSearch(model: model))
|
|
|
|
// The switch is read in `AppModel.webSearchPlan` ahead of this call, so
|
|
// what it guards is exactly this predicate — and the local client is
|
|
// keyless by default, which is what makes the fallback always there.
|
|
#expect(WebSearchClient(backend: .default).isConfigured)
|
|
}
|
|
|
|
/// Chat Completions has nowhere in the request body to put a server tool,
|
|
/// which is a fact about the protocol rather than a guess about the model —
|
|
/// so it sits on its own property and no setting overrides it.
|
|
@Test
|
|
func protocolSupportIsSeparateFromTheModelGuess() {
|
|
var provider = ModelProvider(name: "Gateway")
|
|
#expect(!provider.acceptsNativeWebSearchTool)
|
|
|
|
provider.usesResponsesAPI = true
|
|
#expect(provider.acceptsNativeWebSearchTool)
|
|
|
|
var anthropic = ModelProvider(name: "Anthropic")
|
|
anthropic.kind = .anthropic
|
|
#expect(anthropic.acceptsNativeWebSearchTool)
|
|
|
|
var google = ModelProvider(name: "Google")
|
|
google.kind = .google
|
|
#expect(google.acceptsNativeWebSearchTool)
|
|
}
|
|
|
|
// MARK: - Provider-native search must stay out of the transcript
|
|
|
|
/// The bug this pins produced, on the *second* request of a conversation:
|
|
///
|
|
/// 400 No tool call found for function call output with call_id
|
|
/// provider-web-search-11328F4A-...
|
|
///
|
|
/// A native search is a card, not a turn. The provider ran it inside its own
|
|
/// assistant turn and never asked Termind for anything, so a transcript that
|
|
/// carries it as a tool result replays a `function_call_output` matching no
|
|
/// `function_call`. The first request looks fine because it is the one that
|
|
/// puts the orphan into the history; the next one carries it and is rejected.
|
|
@Test @MainActor
|
|
func providerNativeSearchMakesACardButNotATranscriptTurn() {
|
|
let conversation = ConversationModel()
|
|
let invocation = self.invocation(query: "swift 6")
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "web_search", summary: "Web search run by the model provider.",
|
|
inputSchema: .object(["type": .string("object")]),
|
|
target: .network, safety: .locallyReadOnly
|
|
)
|
|
|
|
conversation.handle(.toolProposed(invocation, descriptor))
|
|
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
|
callID: invocation.call.id,
|
|
content: "[]",
|
|
metadata: [AgentToolResult.providerNativeKey: .bool(true)]
|
|
)))
|
|
|
|
#expect(conversation.toolCards.count == 1)
|
|
#expect(conversation.transcript.isEmpty)
|
|
// And so nothing reaches the next request's history.
|
|
#expect(conversation.pendingPriorMessages.isEmpty)
|
|
}
|
|
|
|
/// The other half: an ordinary tool result still becomes a turn, or the
|
|
/// model never learns what its own tool returned.
|
|
@Test @MainActor
|
|
func ordinaryToolResultsStillEnterTheTranscript() {
|
|
let conversation = ConversationModel()
|
|
let invocation = AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "call-1", name: "web_search",
|
|
arguments: .object(["query": .string("swift 6")])
|
|
)
|
|
)
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "web_search", summary: "Search the public web.",
|
|
inputSchema: .object(["type": .string("object")]),
|
|
target: .network, safety: .locallyReadOnly
|
|
)
|
|
|
|
conversation.handle(.toolProposed(invocation, descriptor))
|
|
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
|
callID: "call-1", content: "<untrusted-data>\n[]\n</untrusted-data>"
|
|
)))
|
|
|
|
#expect(conversation.transcript.count == 1)
|
|
#expect(conversation.transcript[0].role == .tool)
|
|
#expect(conversation.transcript[0].toolCallID == "call-1")
|
|
}
|
|
|
|
/// Repairs conversations already damaged by the bug above.
|
|
///
|
|
/// A run persists its snapshot before the request goes out, so the failing
|
|
/// request's own history was written to disk. Without this, an affected
|
|
/// conversation keeps carrying the orphan forward and keeps being rejected,
|
|
/// restart or not.
|
|
@Test
|
|
func orphanedToolResultsAreDroppedFromIncomingHistory() {
|
|
let history: [AgentTranscriptMessage] = [
|
|
AgentTranscriptMessage(role: .user, text: "look it up"),
|
|
AgentTranscriptMessage(
|
|
role: .assistant, text: "",
|
|
toolCalls: [AgentToolCall(
|
|
id: "call-1", name: "ssh_exec", arguments: .object([:])
|
|
)]
|
|
),
|
|
AgentTranscriptMessage(
|
|
role: .tool, text: "ok", toolCallID: "call-1", toolName: "ssh_exec"
|
|
),
|
|
AgentTranscriptMessage(
|
|
role: .tool, text: "[]",
|
|
toolCallID: "provider-web-search-11328F4A", toolName: "web_search"
|
|
),
|
|
AgentTranscriptMessage(role: .assistant, text: "Done."),
|
|
]
|
|
|
|
let repaired = AgentOrphanedToolResultRepair.apply(history)
|
|
|
|
#expect(repaired.count == 4)
|
|
#expect(!repaired.contains { $0.toolCallID?.hasPrefix("provider-web-search") == true })
|
|
// The answered one survives, or the model loses what its tool returned.
|
|
#expect(repaired.contains { $0.toolCallID == "call-1" })
|
|
}
|
|
|
|
@Test
|
|
func toolResultsWithoutACallIDAreKept() {
|
|
// Google addresses a tool result by name, not by id, so an absent id is
|
|
// not evidence of an orphan.
|
|
let history = [
|
|
AgentTranscriptMessage(role: .tool, text: "ok", toolName: "list_hosts")
|
|
]
|
|
#expect(AgentOrphanedToolResultRepair.apply(history).count == 1)
|
|
}
|
|
|
|
private func invocation(query: String) -> AgentToolInvocation {
|
|
AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "call-1", name: "web_search",
|
|
arguments: .object(["query": .string(query)])
|
|
)
|
|
)
|
|
}
|
|
}
|