Skip to content

IWebSearchProvider ships seven implementations across five packages with no shared contract suite — the same structure #904 was filed for #948

Description

@sroussey

What

packages/web-search landed this window (3,231 LOC of source, 16 co-located test files, 137 tests green). It defines IWebSearchProvider plus a WebSearchCapabilities record that the task enforces, and seven implementations shipped in the same window:

$ grep -rn 'capabilities: WebSearchCapabilities = {' packages/web-search/src/providers/*.ts providers/*/src/web-search/*.ts
packages/web-search/src/providers/BraveWebSearchProvider.ts:58
packages/web-search/src/providers/SearxngWebSearchProvider.ts:37
packages/web-search/src/providers/TavilyWebSearchProvider.ts:34
providers/anthropic/src/web-search/AnthropicWebSearchProvider.ts:61
providers/google-gemini/src/web-search/GeminiWebSearchProvider.ts:65
providers/openai/src/web-search/OpenAiWebSearchProvider.ts:42
providers/openrouter/src/web-search/OpenRouterWebSearchProvider.ts:46

Seven implementations, five packages, three vendor SDKs and one HTTP adapter family. packages/test/src/contract/ holds shared suites for nine surfaces:

$ ls packages/test/src/contract/
ai-provider  browser-context  entitlement-profile  human-connector
storage-migrations  tabular-migrations  tabular-storage  vector-storage  worker-proxy

None for web search. Every provider is verified only by its own hand-written file:

10  packages/web-search/src/__tests__/BraveWebSearchProvider.test.ts
 8  packages/web-search/src/__tests__/TavilyWebSearchProvider.test.ts
 8  packages/web-search/src/__tests__/SearxngWebSearchProvider.test.ts
16  providers/anthropic/src/__tests__/AnthropicWebSearchProvider.test.ts
13  providers/openrouter/src/__tests__/OpenRouterWebSearchProvider.test.ts
12  providers/openai/src/__tests__/OpenAiWebSearchProvider.test.ts
 9  providers/google-gemini/src/__tests__/GeminiWebSearchProvider.test.ts

The per-provider coverage is real and not the complaint. The complaint is that nothing states the cross-provider properties, and the package's own docs say those properties are what the whole design exists for.

The properties that are declared and unasserted

.claude/CLAUDE.md states each of these as a rule the package enforces. Each is currently enforced by whoever wrote each adapter:

  • A declared capability is honoured. "Over-declaring is the failure the whole record prevents: \"auto\" would route an option to a provider that cannot honor it and the adapter would throw after selection rather than before." Nothing asserts that a provider declaring dateFilter: true actually sends a bound for every range the task accepts — which is the explicit half-open-range rule: "both Brave's freshness and Gemini's timeRangeFilter take a closed interval, so a half-open range is filled at the open end. Dropping it reports a bound as honored on a search that ran unfiltered." Three providers declare dateFilter: true; the rule is asserted per-file, not per-interface.
  • maxResultsCap means the same thing everywhere. 4a4d272002 is literally titled "make maxResults mean the same thing for every provider" — a cross-provider property fixed once, pinned nowhere shared.
  • A domain entry naming no domain is refused, for every provider. b88ee912a then c8e49bd6d ("reduce domain entries on every route, not just site:") is the same property found twice in five days, because the first fix landed on one route.
  • context.signal reaches the vendor call. "Each hands context.signal to the SDK call, not just to a throwIfAborted() before it — otherwise an aborted run leaves a grounded turn in flight and pays for it." Four grounded adapters, four separate chances to forget.

I checked the one over-declaration I expected to find and it is not there — OpenRouterWebSearchProvider declares domainFilter: "native" with no excludeDomainFilter, which defaults it to "native", and the adapter does send both lists (OpenRouterWebSearchProvider.ts:97-98, plugin.include_domains / plugin.exclude_domains). So this is a structural gap, not a live defect today. That is the same state the vector interface was in before #889 made every similaritySearch fail.

Why it matters

This is the exact structure that produced #904/#941: a multi-implementation interface with per-implementation tests and no shared contract, where the first cross-cutting bug is found by a caller. The window that closed it for IVectorStorage027dfa713, "Add the IVectorStorage contract suite, and run it on six implementations" — opened the identical hole one interface over, in the same seven days.

The commit record already shows the cost: 9 feat(web-search) commits and 12 fix(web-search) commits in one window, and at least three of those fixes (4a4d272002, c8e49bd6d, 1c07c8656) are "make property P true on every route" — the shape a contract suite turns into one assertion.

Proposed fix

packages/test/src/contract/web-search-provider/runWebSearchProviderContract.ts, called from each provider's own test file with a fake transport, asserting over a derived capability record rather than a written-down list (guardParity.ts is the in-repo template for deriving the set from the object):

  • for every true/"native"/"query-operator" in capabilities, the corresponding request option reaches the transport;
  • for every false, the task refuses rather than the adapter dropping it;
  • a half-open date range produces a closed interval when dateFilter: true;
  • maxResults above maxResultsCap clamps and below it passes through, with the returned length honoured;
  • an aborted context.signal reaches the transport rather than only being checked before it;
  • an empty / whitespace / parenthesised domain entry is refused on every route.

SearxngWebSearchProvider is the free reference implementation: it is the only one with no API key and no quota, which is why its integration test already runs unmocked.

Measured on origin/main @ 2d36880 (0.6.0). bun scripts/test.ts vitest unit web-search → 15 files / 137 tests, all pass.

Found during the 2026-09-14 review of packages/. Related: #941 (the vector half, still 6 of 8).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions