From 1c339219c18faa3fa9f32684e7f4125a8da71ca2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 23 Sep 2026 12:57:33 -0500 Subject: [PATCH] fix(core): share web search limits with OpenCode Web Search OpenCode Web Search, the default search provider for Console-connected users, made its own request with no size cap and discarded the body of failed responses. It now uses the shared response handling, and OpenCode API error bodies ({ _tag, message }) supply the failure explanation. Reading a failed response for its explanation is bounded to one second so a stalled error body still fails promptly. Also formats the earlier changes with Prettier and tidies the tests. --- packages/core/src/plugin/provider/opencode.ts | 36 ++++---- .../core/src/plugin/websearch/parallel.ts | 4 +- .../core/src/plugin/websearch/response.ts | 38 +++++---- packages/core/src/tool/plugin/websearch.ts | 2 +- packages/core/src/websearch.ts | 4 +- .../test/plugin/provider-opencode.test.ts | 36 +++++++- packages/core/test/plugin/websearch.test.ts | 82 +++++++++---------- 7 files changed, 125 insertions(+), 77 deletions(-) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 331e802068a..758f3110009 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect" +import { Duration, Effect, Equal, Option, Schema, Semaphore, Stream } from "effect" import type { Scope } from "effect" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" import { define } from "@opencode/plugin/effect/plugin" @@ -11,6 +11,7 @@ import { IntegrationConnection } from "../../integration/connection.js" import { ManagedPolicy } from "../../managed-policy.js" import { Provider } from "../../provider.js" import { WebSearch } from "../../websearch.js" +import { WebSearchResponse } from "../websearch/response.js" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ConfigProvider } from "@opencode/schema/config/provider" import { Money } from "@opencode/schema/money" @@ -45,6 +46,9 @@ const TokenPending = Schema.Struct({ error: Schema.String }) const DeviceToken = Schema.Union([Token, TokenPending]) const User = Schema.Struct({ id: Schema.String, email: Schema.String }) const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) +const decodeWebSearchResponse = Schema.decodeUnknownOption( + Schema.Struct({ providerID: WebSearch.ID, results: Schema.Array(Schema.Unknown) }), +) function oauth(http: HttpClient.HttpClient) { return { @@ -313,25 +317,27 @@ export const OpencodePlugin = define Effect.fail(new Error("OpenCode web search request timed out")), - }), - ) - if (response.providerID !== descriptor.providerID) { + const body = yield* WebSearchResponse.execute(http, request).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), + Effect.scoped, + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.fail(new Error("OpenCode web search request timed out")), + }), + ) + const response = WebSearchResponse.json(body.text, body.truncated).pipe( + Option.flatMap(decodeWebSearchResponse), + ) + if (Option.isNone(response)) + return yield* Effect.fail(new Error("OpenCode web search returned an invalid response")) + if (response.value.providerID !== descriptor.providerID) { return yield* Effect.fail( new Error( - `OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`, + `OpenCode web search returned provider ${response.value.providerID} instead of ${descriptor.providerID}`, ), ) } - return response.results + return WebSearchResponse.items(WebSearch.Result, response.value.results, body.truncated) }), }) editor.default.set(descriptor.providerID) diff --git a/packages/core/src/plugin/websearch/parallel.ts b/packages/core/src/plugin/websearch/parallel.ts index 3371f3d210f..0f7407c6072 100644 --- a/packages/core/src/plugin/websearch/parallel.ts +++ b/packages/core/src/plugin/websearch/parallel.ts @@ -71,7 +71,9 @@ export const Plugin = define({ const search = Option.getOrUndefined( decodeSearchResponse( response.result?.structuredContent ?? - (content ? Option.getOrUndefined(WebSearchResponse.json(content.text, response.truncated)) : undefined), + (content + ? Option.getOrUndefined(WebSearchResponse.json(content.text, response.truncated)) + : undefined), ), ) return WebSearchResponse.items(SearchResult, search?.results ?? [], response.truncated).map((item) => { diff --git a/packages/core/src/plugin/websearch/response.ts b/packages/core/src/plugin/websearch/response.ts index 29553f7ce1d..eca27491227 100644 --- a/packages/core/src/plugin/websearch/response.ts +++ b/packages/core/src/plugin/websearch/response.ts @@ -1,9 +1,8 @@ export * as WebSearchResponse from "./response.js" import { parseJSON } from "@opencode/ai/protocols/utils/partial-json" -import { Effect, Option, Schema, Stream } from "effect" -import { HttpClient, HttpClientError } from "effect/unstable/http" -import type { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { Duration, Effect, Option, Schema, Stream } from "effect" +import { HttpClient, HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" export const MAX_BYTES = 1024 * 1024 @@ -12,22 +11,27 @@ export const execute = (http: HttpClient.HttpClient, request: HttpClientRequest. Effect.gen(function* () { const response = yield* HttpClient.withScope(http).execute(request) if (response.status >= 200 && response.status < 300) return yield* read(response) - const description = yield* read(response).pipe( - Effect.map((body) => - payloads(body.text) - .flatMap((payload) => Option.toArray(json(payload, body.truncated))) - .map(failure) - .find((message) => message !== undefined), - ), - Effect.orElseSucceed(() => undefined), - ) return yield* new HttpClientError.HttpClientError({ - reason: new HttpClientError.StatusCodeError({ request, response, description }), + reason: new HttpClientError.StatusCodeError({ + request, + response, + description: yield* read(response).pipe( + Effect.map((body) => + payloads(body.text) + .flatMap((payload) => Option.toArray(json(payload, body.truncated))) + .map(failure) + .find((message) => message !== undefined), + ), + // The explanation is optional; a slow or broken body must not delay the failure. + Effect.timeoutOrElse({ duration: Duration.seconds(1), orElse: () => Effect.undefined }), + Effect.orElseSucceed(() => undefined), + ), + }), }) }) // Stops reading at MAX_BYTES instead of failing; parsers keep the results that arrived complete. -export const read = (response: HttpClientResponse.HttpClientResponse) => +const read = (response: HttpClientResponse.HttpClientResponse) => Effect.gen(function* () { let size = 0 const chunks = yield* response.stream.pipe( @@ -58,16 +62,18 @@ const decodeFailure = Schema.decodeUnknownOption( }), }), Schema.Struct({ detail: Schema.Struct({ error: Schema.String }) }), + Schema.Struct({ _tag: Schema.String, message: Schema.String }), ]), ) -// JSON-RPC errors, MCP tool errors, and Tavily's error detail. +// JSON-RPC errors, MCP tool errors, Tavily's error detail, and OpenCode API errors. export const failure = (value: unknown) => Option.getOrUndefined( Option.map(decodeFailure(value), (decoded) => { if ("error" in decoded) return decoded.error.message if ("result" in decoded) return decoded.result.content.map((item) => item.text).join("\n") - return decoded.detail.error + if ("detail" in decoded) return decoded.detail.error + return decoded.message }), ) diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index 84606ffec19..68096ce05ac 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -162,7 +162,7 @@ export const Plugin = { const message = `Unable to search the web for ${input.query}` // A cause becomes the model-visible error, so web search failures are described here instead; // other causes, such as permission errors, keep their own session error. - if (Schema.is(WebSearch.RequestError)(error)) + if (error instanceof WebSearch.RequestError) return new ToolFailure({ message: `${message} (${error.providerID}): ${error.message}`, metadata: { provider: error.providerID }, diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index 89fcb3ebc77..86667082580 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -75,7 +75,9 @@ export class RequestError extends Schema.TaggedError()("WebSearch. if (status === 429) return "Rate limited (HTTP 429)" if (status === 401) return "Authentication failed (HTTP 401)" if (status !== undefined) return `Request failed (HTTP ${status})` - return cause.cause instanceof Error && cause.cause.message ? `Request failed: ${cause.cause.message}` : "Request failed" + return cause.cause instanceof Error && cause.cause.message + ? `Request failed: ${cause.cause.message}` + : "Request failed" } } diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 180390bfb4b..a84697d9729 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -17,6 +17,7 @@ import { ModelResolver } from "@opencode/core/model-resolver" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode" +import { WebSearchResponse } from "@opencode/core/plugin/websearch/response" import { Provider } from "@opencode/core/provider" import { WebSearch } from "@opencode/core/websearch" import { withEnv } from "../fixture/env" @@ -805,7 +806,13 @@ describe("OpencodePlugin", () => { body?: unknown }> = [] const gate = Promise.withResolvers() - const state = { advertised: true, providerID: "opencode", waitForConfig: false } + const state = { + advertised: true, + providerID: "opencode", + waitForConfig: false, + unavailable: false, + oversized: false, + } const server = Bun.serve({ port: 0, fetch: async (request) => { @@ -832,6 +839,11 @@ describe("OpencodePlugin", () => { }) } if (path === "/api/websearch" || path === "/other/api/websearch") { + if (state.unavailable) + return Response.json( + { _tag: "ServiceUnavailableError", message: "Web search request failed: firecrawl" }, + { status: 503 }, + ) return Response.json({ providerID: state.providerID, results: [ @@ -841,6 +853,16 @@ describe("OpencodePlugin", () => { content: "Open source AI coding agent.", time: { published: 1_700_000_000_000 }, }, + ...(state.oversized + ? [ + { + url: "https://huge.example.com", + content: "x".repeat(2 * WebSearchResponse.MAX_BYTES), + time: {}, + }, + { url: "https://after.example.com", content: "after", time: {} }, + ] + : []), ], }) } @@ -927,6 +949,18 @@ describe("OpencodePlugin", () => { value: account("replacement"), }) + state.oversized = true + expect((yield* websearch.query({ query: "oversized" })).results.map((result) => result.url)).toEqual([ + "https://github.com/anomalyco/opencode", + ]) + state.oversized = false + + state.unavailable = true + expect((yield* websearch.query({ query: "unavailable" }).pipe(Effect.flip)).message).toBe( + "HTTP 503: Web search request failed: firecrawl", + ) + state.unavailable = false + state.providerID = "unexpected" expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request") diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index 4ff2e64ad11..815b4fc0058 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -30,7 +30,8 @@ beforeEach(() => { }) const it = webSearchIntegrationTest -const sseMessage = (message: object) => `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 1, ...message })}\n\n` +const sseMessage = (message: object) => + `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 1, ...message })}\n\n` describe("built-in web search providers", () => { ;[ @@ -97,8 +98,7 @@ describe("built-in web search providers", () => { describe("responses larger than the size cap", () => { const huge = "x".repeat(2 * WebSearchResponse.MAX_BYTES) - const sse = (result: object) => sseMessage({ result }) - const text = (value: string) => ({ content: [{ type: "text", text: value }] }) + const mcpText = (text: string) => sseMessage({ result: { content: [{ type: "text", text }] } }) const exaBlock = (url: string, title: string, content: string) => `Title: ${title}\nURL: ${url}\nPublished: N/A\nAuthor: N/A\nHighlights:\n${content}` const parallelSearch = { @@ -113,43 +113,46 @@ describe("built-in web search providers", () => { ;[ { plugin: WebSearchExa.Plugin, - body: sse( - text( - [ - exaBlock("https://effect.website", "Effect", "Effect documentation"), - exaBlock("https://huge.example.com", "Huge", huge), - exaBlock("https://after.example.com", "After", "after"), - ].join("\n\n---\n\n"), - ), + providerID: WebSearch.ID.make("exa"), + body: mcpText( + [ + exaBlock("https://effect.website", "Effect", "Effect documentation"), + exaBlock("https://huge.example.com", "Huge", huge), + exaBlock("https://after.example.com", "After", "after"), + ].join("\n\n---\n\n"), ), }, { plugin: WebSearchFirecrawl.Plugin, - body: sse( - text( - JSON.stringify({ - success: true, - data: { - web: [ - { url: "https://effect.website", title: "Effect", description: "Effect documentation" }, - { url: "https://huge.example.com", title: "Huge", description: huge }, - { url: "https://after.example.com", title: "After", description: "after" }, - ], - }, - }), - ), + providerID: WebSearch.ID.make("firecrawl"), + body: mcpText( + JSON.stringify({ + success: true, + data: { + web: [ + { url: "https://effect.website", title: "Effect", description: "Effect documentation" }, + { url: "https://huge.example.com", title: "Huge", description: huge }, + { url: "https://after.example.com", title: "After", description: "after" }, + ], + }, + }), ), }, { plugin: WebSearchParallel.Plugin, + providerID: WebSearch.ID.make("parallel"), body: JSON.stringify({ jsonrpc: "2.0", id: 1, - result: { ...text(JSON.stringify(parallelSearch)), structuredContent: parallelSearch }, + result: { + content: [{ type: "text", text: JSON.stringify(parallelSearch) }], + structuredContent: parallelSearch, + }, }), }, { plugin: WebSearchTavily.Plugin, + providerID: WebSearch.ID.make("tavily"), body: JSON.stringify({ results: [ { title: "Effect", url: "https://effect.website", content: "Effect documentation" }, @@ -160,16 +163,15 @@ describe("built-in web search providers", () => { }, { plugin: WebSearchTinyFish.Plugin, - body: sse( - text( - JSON.stringify({ - results: [ - { title: "Effect", url: "https://effect.website", snippet: "Effect documentation" }, - { title: "Huge", url: "https://huge.example.com", snippet: huge }, - { title: "After", url: "https://after.example.com", snippet: "after" }, - ], - }), - ), + providerID: WebSearch.ID.make("tinyfish"), + body: mcpText( + JSON.stringify({ + results: [ + { title: "Effect", url: "https://effect.website", snippet: "Effect documentation" }, + { title: "Huge", url: "https://huge.example.com", snippet: huge }, + { title: "After", url: "https://after.example.com", snippet: "after" }, + ], + }), ), }, ].forEach((provider) => { @@ -181,11 +183,10 @@ describe("built-in web search providers", () => { yield* provider.plugin.effect( host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), ) - const providerID = (yield* websearch.providers())[0]!.id - expect(yield* websearch.query({ query: "effect", providerID })).toEqual( + expect(yield* websearch.query({ query: "effect", providerID: provider.providerID })).toEqual( new WebSearch.Response({ - providerID, + providerID: provider.providerID, results: [{ url: "https://effect.website", title: "Effect", content: "Effect documentation", time: {} }], }), ) @@ -225,10 +226,7 @@ describe("built-in web search providers", () => { const quota = "Free daily Search quota used (50/50). Sign up for continued access: https://agent.tinyfish.ai/sign-up" - resetWebSearchFixture( - JSON.stringify({ jsonrpc: "2.0", error: { code: -31001, message: quota }, id: 1 }), - 401, - ) + resetWebSearchFixture(JSON.stringify({ jsonrpc: "2.0", error: { code: -31001, message: quota }, id: 1 }), 401) const tinyfish = yield* websearch .query({ query: "effect", providerID: WebSearch.ID.make("tinyfish") }) .pipe(Effect.flip)