diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bd54a0dd3..6e220fd07 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,10 @@ - Fixed `/force ` rejecting Ollama/local models before the requested tool could run; Ollama now receives a named forced choice that the provider transport narrows to the selected tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236)) +### Fixed + +- Fixed `web_search` freezing the session when an upstream provider stalled. Bun's WinHTTP backend on Windows can silently drop `AbortSignal` once a TCP/TLS connection hangs (oven-sh/bun#15275, oven-sh/bun#18536), so Esc never reached the in-flight fetch and the only recovery was Ctrl+C + `omp --resume`. Every web-search provider's outbound `fetch` (anthropic, brave, codex, exa, gemini, jina, kagi, kimi, parallel, perplexity, searxng, synthetic, tavily, z.ai) now composes the caller signal with a 60s hard timeout via a shared `withHardTimeout` helper, guaranteeing the request settles within a minute even when Bun's abort fails to propagate. Independently, `executeSearch`'s provider-fallback loop was masking real cancellations as ordinary provider errors and returning "All web search providers failed"; it now re-throws as `ToolAbortError` the moment the caller's signal aborts, so the session sees a clean cancel on every platform. ([#1221](https://github.com/can1357/oh-my-pi/issues/1221)) + ## [15.1.8] - 2026-05-20 ### Fixed diff --git a/packages/coding-agent/src/web/kagi.ts b/packages/coding-agent/src/web/kagi.ts index b9d0d8fbf..252099b4d 100644 --- a/packages/coding-agent/src/web/kagi.ts +++ b/packages/coding-agent/src/web/kagi.ts @@ -1,5 +1,5 @@ import { getEnvApiKey } from "@oh-my-pi/pi-ai"; -import { findCredential } from "./search/providers/utils"; +import { findCredential, withHardTimeout } from "./search/providers/utils"; const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search"; @@ -138,7 +138,7 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions = const response = await fetch(requestUrl, { headers: getAuthHeaders(apiKey), - signal: options.signal, + signal: withHardTimeout(options.signal), }); if (!response.ok) { throw parseKagiErrorResponse(response.status, await response.text()); diff --git a/packages/coding-agent/src/web/parallel.ts b/packages/coding-agent/src/web/parallel.ts index cb1d1fb50..dbf9e8c44 100644 --- a/packages/coding-agent/src/web/parallel.ts +++ b/packages/coding-agent/src/web/parallel.ts @@ -1,5 +1,5 @@ import { getEnvApiKey } from "@oh-my-pi/pi-ai"; -import { findCredential } from "./search/providers/utils"; +import { findCredential, withHardTimeout } from "./search/providers/utils"; const PARALLEL_API_URL = "https://api.parallel.ai"; const PARALLEL_SEARCH_URL = `${PARALLEL_API_URL}/v1beta/search`; @@ -304,7 +304,7 @@ export async function searchWithParallel( max_chars_per_result: options.maxCharsPerResult ?? 10_000, }, }), - signal: options.signal, + signal: withHardTimeout(options.signal), }); if (!response.ok) { throw parseParallelErrorResponse(response.status, await response.text()); @@ -335,7 +335,7 @@ export async function extractWithParallel( excerpts: options.excerpts ?? true, full_content: options.fullContent ?? false, }), - signal: options.signal, + signal: withHardTimeout(options.signal), }); if (!response.ok) { throw parseParallelErrorResponse(response.status, await response.text()); diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 58f6147d2..aead561ab 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -14,6 +14,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" }; import type { ToolSession } from "../../tools"; import { formatAge } from "../../tools/render-utils"; +import { throwIfAborted } from "../../tools/tool-errors"; import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider"; import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render"; import type { SearchProviderId, SearchResponse } from "./types"; @@ -161,6 +162,12 @@ async function executeSearch( details: { response }, }; } catch (error) { + // Surface user-initiated cancellation immediately so the session sees + // a clean abort instead of a generic "all providers failed" message. + // Without this, an AbortError from `fetch()` is treated as a provider + // failure and the loop falls through to the next provider (or to the + // summary error), masking the cancellation. + throwIfAborted(signal); lastError = error; } } diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index b0aa9dfb3..37c3b06f1 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -24,12 +24,12 @@ import type { import { SearchProviderError } from "../../../web/search/types"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; +import { withHardTimeout } from "./utils"; const DEFAULT_MODEL = "claude-haiku-4-5"; const DEFAULT_MAX_TOKENS = 4096; const WEB_SEARCH_TOOL_NAME = "web_search"; const WEB_SEARCH_TOOL_TYPE = "web_search_20250305"; - export interface AnthropicSearchParams { query: string; system_prompt?: string; @@ -118,7 +118,7 @@ async function callSearch( method: "POST", headers, body: JSON.stringify(body), - signal, + signal: withHardTimeout(signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/brave.ts b/packages/coding-agent/src/web/search/providers/brave.ts index 03c284f07..fcb30ff6a 100644 --- a/packages/coding-agent/src/web/search/providers/brave.ts +++ b/packages/coding-agent/src/web/search/providers/brave.ts @@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { clampNumResults, dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; -import { isApiKeyAvailable } from "./utils"; +import { isApiKeyAvailable, withHardTimeout } from "./utils"; const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"; const DEFAULT_NUM_RESULTS = 10; @@ -85,7 +85,7 @@ async function callBraveSearch( Accept: "application/json", "X-Subscription-Token": apiKey, }, - signal: params.signal, + signal: withHardTimeout(params.signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index 3a10b2db5..df7c6281d 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; +import { withHardTimeout } from "./utils"; const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const CODEX_RESPONSES_PATH = "/codex/responses"; @@ -338,7 +339,7 @@ async function callCodexSearch( method: "POST", headers, body: JSON.stringify(body), - signal: options.signal, + signal: withHardTimeout(options.signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index 72c746ba9..85febadec 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -14,6 +14,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; +import { withHardTimeout } from "./utils"; const EXA_API_URL = "https://api.exa.ai/search"; @@ -180,7 +181,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`; diff --git a/packages/coding-agent/src/web/search/providers/jina.ts b/packages/coding-agent/src/web/search/providers/jina.ts index 3035aa780..2895ff8dd 100644 --- a/packages/coding-agent/src/web/search/providers/jina.ts +++ b/packages/coding-agent/src/web/search/providers/jina.ts @@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; -import { isApiKeyAvailable } from "./utils"; +import { isApiKeyAvailable, withHardTimeout } from "./utils"; const JINA_SEARCH_URL = "https://s.jina.ai"; @@ -41,7 +41,7 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna Accept: "application/json", Authorization: `Bearer ${apiKey}`, }, - signal, + signal: withHardTimeout(signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/kimi.ts b/packages/coding-agent/src/web/search/providers/kimi.ts index 7297d3ae7..6e7e503cb 100644 --- a/packages/coding-agent/src/web/search/providers/kimi.ts +++ b/packages/coding-agent/src/web/search/providers/kimi.ts @@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { clampNumResults, dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; -import { findCredential, isApiKeyAvailable } from "./utils"; +import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils"; const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search"; @@ -78,7 +78,7 @@ async function callKimiSearch( enable_page_crawling: params.includeContent, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, }), - signal: params.signal, + signal: withHardTimeout(params.signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index 779870e60..f2ff5278d 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -22,6 +22,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; +import { withHardTimeout } from "./utils"; const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"; const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask"; @@ -247,7 +248,7 @@ async function callPerplexityApi( "Content-Type": "application/json", }, body: JSON.stringify(request), - signal, + signal: withHardTimeout(signal), }); if (!response.ok) { @@ -364,7 +365,7 @@ async function callPerplexityOAuth( skip_search_enabled: true, }, }), - signal: params.signal, + signal: withHardTimeout(params.signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/searxng.ts b/packages/coding-agent/src/web/search/providers/searxng.ts index c2a40106e..368347912 100644 --- a/packages/coding-agent/src/web/search/providers/searxng.ts +++ b/packages/coding-agent/src/web/search/providers/searxng.ts @@ -31,6 +31,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { clampNumResults, dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; +import { withHardTimeout } from "./utils"; const DEFAULT_NUM_RESULTS = 10; const MAX_NUM_RESULTS = 20; @@ -211,7 +212,7 @@ async function callSearXNGSearch( const response = await fetch(url, { headers, - signal: params.signal, + signal: withHardTimeout(params.signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/synthetic.ts b/packages/coding-agent/src/web/search/providers/synthetic.ts index 7e470e27c..985b42c91 100644 --- a/packages/coding-agent/src/web/search/providers/synthetic.ts +++ b/packages/coding-agent/src/web/search/providers/synthetic.ts @@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; -import { findCredential, isApiKeyAvailable } from "./utils"; +import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils"; const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search"; @@ -43,7 +43,7 @@ async function callSyntheticSearch( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ query }), - signal, + signal: withHardTimeout(signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/tavily.ts b/packages/coding-agent/src/web/search/providers/tavily.ts index 4072aeef3..b1d3fd219 100644 --- a/packages/coding-agent/src/web/search/providers/tavily.ts +++ b/packages/coding-agent/src/web/search/providers/tavily.ts @@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { clampNumResults, dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; -import { findCredential, isApiKeyAvailable } from "./utils"; +import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils"; const TAVILY_SEARCH_URL = "https://api.tavily.com/search"; const DEFAULT_NUM_RESULTS = 5; @@ -92,7 +92,7 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(buildRequestBody(params)), - signal: params.signal, + signal: withHardTimeout(params.signal), }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/utils.ts b/packages/coding-agent/src/web/search/providers/utils.ts index 2be5368f1..b6eb73b0d 100644 --- a/packages/coding-agent/src/web/search/providers/utils.ts +++ b/packages/coding-agent/src/web/search/providers/utils.ts @@ -49,6 +49,36 @@ export async function isApiKeyAvailable(findApiKey: () => string | null | Promis } } +/** + * Default hard ceiling for a single web-search round-trip. 60s tolerates + * legitimate slow LLM-mediated responses (anthropic web_search_20250305, + * perplexity, gemini, codex) while still guaranteeing the session unfreezes + * within a minute if Bun's `AbortSignal` fails to propagate on Windows. + * + * Pure search APIs (brave, exa, jina, tavily, searxng, synthetic, zai) + * settle far faster in practice; reusing the same ceiling keeps the wiring + * uniform without compromising correctness. + */ +export const SEARCH_HARD_TIMEOUT_MS = 60_000; + +/** + * Compose a caller-supplied {@link AbortSignal} with a hard timeout so an + * outbound `fetch()` is guaranteed to settle within `ms` even when the + * runtime fails to propagate cancellation to the underlying transport. + * + * Bun's WinHTTP backend on Windows is known to ignore `AbortSignal` once a + * TCP/TLS connection stalls (oven-sh/bun#15275, oven-sh/bun#18536); without + * this safety net a stalled web-search request freezes the entire session + * because the user's Esc is never delivered to the native layer. + * + * @param signal - Caller cancellation signal, if any. + * @param ms - Hard timeout in milliseconds. Defaults to {@link SEARCH_HARD_TIMEOUT_MS}. + */ +export function withHardTimeout(signal: AbortSignal | undefined, ms: number = SEARCH_HARD_TIMEOUT_MS): AbortSignal { + const timeout = AbortSignal.timeout(ms); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + /** * Map a provider's raw source list to the unified SearchSource shape, * clamped to the requested result count and annotated with ageSeconds. diff --git a/packages/coding-agent/src/web/search/providers/zai.ts b/packages/coding-agent/src/web/search/providers/zai.ts index e0bee6165..bbdefe445 100644 --- a/packages/coding-agent/src/web/search/providers/zai.ts +++ b/packages/coding-agent/src/web/search/providers/zai.ts @@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types"; import { dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; -import { findCredential, isApiKeyAvailable } from "./utils"; +import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils"; const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp"; const ZAI_TOOL_NAME = "web_search_prime"; @@ -73,7 +73,7 @@ async function callZaiTool(apiKey: string, args: Record, signal arguments: args, }, }), - signal, + signal: withHardTimeout(signal), }); if (!response.ok) { diff --git a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts new file mode 100644 index 000000000..65b95a308 --- /dev/null +++ b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts @@ -0,0 +1,177 @@ +/** + * Regression coverage for issue #1221: `web_search` froze when an upstream + * provider stalled because Bun's WinHTTP fetch could ignore `AbortSignal`, + * and `executeSearch` masked the eventual `AbortError` as a normal provider + * failure. + * + * The fix has two halves: a hard-timeout safety net wrapped around every + * provider's outbound fetch (via the shared `withHardTimeout` helper), and + * an abort re-throw in the provider-fallback loop so the session sees a real + * cancellation instead of "all providers failed". The provider wiring is + * spot-checked on anthropic (LLM-backed) and brave (pure search API); the + * helper itself is exercised directly. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { hookFetch } from "@oh-my-pi/pi-utils"; +import type { ToolSession } from "../../../src/tools"; +import { ToolAbortError } from "../../../src/tools/tool-errors"; +import { WebSearchTool } from "../../../src/web/search"; +import * as provider from "../../../src/web/search/provider"; +import { searchAnthropic } from "../../../src/web/search/providers/anthropic"; +import type { SearchParams } from "../../../src/web/search/providers/base"; +import { searchBrave } from "../../../src/web/search/providers/brave"; +import { withHardTimeout } from "../../../src/web/search/providers/utils"; +import type { SearchProviderId, SearchResponse } from "../../../src/web/search/types"; + +const FAKE_SESSION = {} as ToolSession; + +describe("withHardTimeout", () => { + it("returns a signal that aborts on the hard timeout when no caller signal is supplied", async () => { + const signal = withHardTimeout(undefined, 10); + await Bun.sleep(40); + expect(signal.aborted).toBe(true); + }); + + it("forwards a caller signal's abort to the composed signal", () => { + const ac = new AbortController(); + const signal = withHardTimeout(ac.signal, 60_000); + ac.abort(new Error("user-cancel")); + expect(signal.aborted).toBe(true); + }); + + it("fires the hard timeout even when the caller signal stays open", async () => { + const ac = new AbortController(); + const signal = withHardTimeout(ac.signal, 10); + await Bun.sleep(40); + expect(signal.aborted).toBe(true); + expect(ac.signal.aborted).toBe(false); + }); +}); + +describe("Anthropic provider hard-timeout wiring", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.ANTHROPIC_SEARCH_API_KEY; + }); + + it("passes a composed signal to fetch even when the caller did not supply one", async () => { + process.env.ANTHROPIC_SEARCH_API_KEY = "sk-test"; + + let capturedSignal: AbortSignal | null | undefined; + using _hook = hookFetch(async (_input, init) => { + capturedSignal = init?.signal; + return new Response(JSON.stringify({ content: [{ type: "text", text: "ok" }], usage: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await searchAnthropic({ query: "ping", system_prompt: "" }); + + // Without the hard-timeout wrapper, init.signal would be undefined when + // the caller didn't supply one — leaving fetch with no cancellation at + // all on a stalled WinHTTP connection. + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal?.aborted).toBe(false); + }); + + it("composes the caller signal with the hard timeout instead of forwarding it directly", async () => { + process.env.ANTHROPIC_SEARCH_API_KEY = "sk-test"; + + const ac = new AbortController(); + let capturedSignal: AbortSignal | null | undefined; + using _hook = hookFetch(async (_input, init) => { + capturedSignal = init?.signal; + return new Response(JSON.stringify({ content: [{ type: "text", text: "ok" }], usage: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await searchAnthropic({ query: "ping", system_prompt: "", signal: ac.signal }); + + // The signal handed to fetch must be a *composed* one, not the raw + // caller signal: that's what guarantees the hard timeout fires even + // when Bun fails to honour the caller's abort. + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal).not.toBe(ac.signal); + }); +}); + +describe("Brave provider hard-timeout wiring", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.BRAVE_API_KEY; + }); + + it("hands fetch a composed signal even with no caller signal — confirms the rollout reaches non-Anthropic providers", async () => { + process.env.BRAVE_API_KEY = "brave-test-key"; + + let capturedSignal: AbortSignal | null | undefined; + using _hook = hookFetch(async (_input, init) => { + capturedSignal = init?.signal; + return new Response(JSON.stringify({ web: { results: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await searchBrave({ query: "ping" }); + + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal?.aborted).toBe(false); + }); +}); + +describe("executeSearch abort propagation", () => { + afterEach(() => vi.restoreAllMocks()); + + function fakeProvider(behaviour: (params: SearchParams) => Promise): provider.SearchProvider { + const id: SearchProviderId = "anthropic"; + return { + id, + label: "Anthropic", + isAvailable: () => true, + search: behaviour, + }; + } + + it("surfaces caller cancellation as ToolAbortError instead of falling through to the next provider", async () => { + // Two providers: the first throws an AbortError after the caller aborted, + // the second would happily return a value. Pre-fix, executeSearch would + // fall through to provider B and report success; post-fix, the abort + // re-throw stops the loop immediately. + const secondProviderSearch = vi.fn(); + vi.spyOn(provider, "resolveProviderChain").mockResolvedValue([ + fakeProvider(async () => { + throw new DOMException("aborted", "AbortError"); + }), + fakeProvider(secondProviderSearch), + ]); + + const tool = new WebSearchTool(FAKE_SESSION); + const ac = new AbortController(); + ac.abort(); + + await expect(tool.execute("test-id", { query: "anything" }, ac.signal)).rejects.toBeInstanceOf(ToolAbortError); + expect(secondProviderSearch).not.toHaveBeenCalled(); + }); + + it("still reports provider failures as a tool result when the caller has not aborted", async () => { + // Defensive: the abort re-throw must NOT alter normal provider-error + // flow. A genuine provider error should still produce an error result + // rather than throwing. + vi.spyOn(provider, "resolveProviderChain").mockResolvedValue([ + fakeProvider(async () => { + throw new Error("upstream 500"); + }), + ]); + + const tool = new WebSearchTool(FAKE_SESSION); + const result = await tool.execute("test-id", { query: "anything" }); + const block = result.content[0]; + expect(block?.type).toBe("text"); + expect(block && "text" in block ? block.text : "").toContain("upstream 500"); + expect(result.details?.error).toContain("upstream 500"); + }); +});