From 5e16a5dc6689d2fd89e17eecabb3c0798cbafe28 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 10 Aug 2026 19:24:59 +0530 Subject: [PATCH] fix(tui): restore web search failure details (#41545) --- packages/core/src/session/runner/llm.ts | 2 +- .../src/session/runner/publish-llm-event.ts | 11 ++- packages/core/src/tool/plugin/websearch.ts | 39 +++++++++- .../test/session-runner-tool-events.test.ts | 13 ++++ packages/core/test/tool-websearch.test.ts | 56 ++++++++++++++ packages/tui/src/mini/stream-v2.subagent.ts | 7 +- packages/tui/src/mini/stream-v2.transport.ts | 11 ++- .../tui/test/mini/stream-v2.transport.test.ts | 75 +++++++++++++++++++ 8 files changed, 200 insertions(+), 14 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 581224cbb4f..c976e883fe8 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -343,7 +343,7 @@ const layer = Layer.effect( Effect.flatMap(toolOutput.truncate), Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)), Effect.catchTag("Tool.Error", (error) => - publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid), + publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid), ), ), ).pipe(Effect.forkScoped), diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 3607a477205..c1e95cafffb 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -92,8 +92,11 @@ export const createLLMEventPublisher = (bus: Pick, inp progress?: Tool.Metadata } >() - const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }) => - tool.progress === undefined ? {} : { metadata: tool.progress } + const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }, metadata?: Tool.Metadata) => { + if (tool.progress === undefined) return metadata === undefined ? {} : { metadata } + if (metadata === undefined) return { metadata: tool.progress } + return { metadata: { ...tool.progress, ...metadata } } + } const assistantMessageID = input.assistantMessageID let stepStarted = false let stepFailed = false @@ -272,7 +275,7 @@ export const createLLMEventPublisher = (bus: Pick, inp yield* flushFragments() }) - const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) { + const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error, metadata?: Tool.Metadata) { const tool = tools.get(id) if (!tool || tool.settled) return false tool.settled = true @@ -281,7 +284,7 @@ export const createLLMEventPublisher = (bus: Pick, inp assistantMessageID: tool.assistantMessageID, id, error, - ...failureSnapshot(tool), + ...failureSnapshot(tool, metadata), executed: tool.providerExecuted, }) return true diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index 7423fe8b102..4ad30188359 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -3,6 +3,7 @@ export * as WebSearchTool from "./websearch" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema, Semaphore } from "effect" +import { HttpClientError } from "effect/unstable/http" import { Form } from "../../form" import { KV } from "../../kv" import { Permission } from "../../permission" @@ -52,7 +53,13 @@ export const Plugin = { source: { type: "tool", messageID: context.messageID, id: context.id }, }) const search = (): Effect.Effect>, unknown> => - ctx.websearch.query(input).pipe( + websearch.default().pipe( + Effect.flatMap((provider) => { + if (!provider) return ctx.websearch.query(input) + return context + .progress({ provider: provider.id }) + .pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id }))) + }), Effect.catch((error) => { if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) return providerSelectionLock @@ -152,9 +159,33 @@ export const Plugin = { : NO_RESULTS return { output, content, metadata: { provider: output.provider } } }).pipe( - Effect.mapError( - (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), - ), + Effect.mapError((error) => { + const fallback = `Unable to search the web for ${input.query}` + if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error }) + const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined + switch (status) { + case 429: + return new ToolFailure({ + message: "Web search rate limited (HTTP 429)", + error, + metadata: { provider: error.providerID }, + }) + case 401: + return new ToolFailure({ + message: "Web search authentication failed (HTTP 401)", + error, + metadata: { provider: error.providerID }, + }) + case undefined: + return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } }) + default: + return new ToolFailure({ + message: `Web search request failed (HTTP ${status})`, + error, + metadata: { provider: error.providerID }, + }) + } + }), ), }), ) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 69e9d2dfab3..831f289e62f 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -126,6 +126,19 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a }) }) +test("local failure metadata completes the progress snapshot", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(call)) + await Effect.runPromise(publisher.progress(call.id, { phase: "running", provider: "old" })) + await Effect.runPromise( + publisher.failTool(call.id, { type: "tool.execution", message: "failed" }, { provider: "exa" }), + ) + + expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({ + metadata: { phase: "running", provider: "exa" }, + }) +}) + test("failure snapshot retains canonical progress above the default byte limit", async () => { const { published, publisher } = capture("anthropic", { interruptProgress: true }) await Effect.runPromise(publisher.publish(call)) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 885af1bfac6..8275a628263 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect } from "bun:test" import { Deferred, Effect, Layer } from "effect" +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Permission } from "@opencode-ai/core/permission" @@ -7,6 +8,7 @@ import { Form } from "@opencode-ai/core/form" import { KV } from "@opencode-ai/core/kv" import { WebSearch } from "@opencode-ai/core/websearch" import { Session } from "@opencode-ai/core/session" +import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { Tool } from "@opencode-ai/core/tool" import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" @@ -41,6 +43,7 @@ let formResponse: Form.TerminalState = { status: "cancelled" } const formResponses: Form.TerminalState[] = [] let queryBarrier: Deferred.Deferred | undefined let synchronizedQueries = 0 +let queryError: WebSearch.Error | undefined let result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], @@ -56,6 +59,7 @@ beforeEach(() => { formResponses.length = 0 queryBarrier = undefined synchronizedQueries = 0 + queryError = undefined result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], @@ -94,6 +98,7 @@ const websearch = Layer.succeed( if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined) yield* Deferred.await(queryBarrier) } + if (queryError) return yield* queryError if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError() if (typeof stored === "string") return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results }) @@ -376,4 +381,55 @@ describe("WebSearchTool registration", () => { expect(queries).toHaveLength(1) }), ) + + it.effect("reports safe HTTP failures with the attempted provider", () => + Effect.gen(function* () { + const registry = yield* Tool.Service + const tools = yield* registry.snapshot() + values.set("websearch:provider", "exa") + + yield* Effect.forEach( + [ + { status: 403, message: "Web search request failed (HTTP 403)" }, + { status: 429, message: "Web search rate limited (HTTP 429)" }, + { status: 401, message: "Web search authentication failed (HTTP 401)" }, + ], + ({ status, message }, index) => + Effect.gen(function* () { + const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret") + queryError = new WebSearch.RequestError({ + providerID: WebSearch.ID.make("exa"), + cause: new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request, + response: HttpClientResponse.fromWeb(request, new Response(null, { status })), + description: "non 2xx status code", + }), + }), + }) + const progress: Tool.Metadata[] = [] + const error = yield* tools + .execute({ + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: `call-http-${index}`, + name: "websearch", + input: { query: "effect" }, + }, + progress: (metadata) => Effect.sync(() => progress.push(metadata)), + }) + .pipe(Effect.flip) + + const sessionError = toSessionError(error) + expect(sessionError).toEqual({ type: "tool.execution", message }) + expect(sessionError.message).not.toContain("secret") + expect(error.metadata).toEqual({ provider: "exa" }) + expect(progress).toEqual([{ provider: "exa" }]) + }), + { discard: true }, + ) + }), + ) }) diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index 2dc2c76d8b9..bfa4bdbc4e8 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -312,7 +312,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const current = child.tools.get(key) const output = toolOutputText(part.name, toolDisplayContent(part.state)) if (part.state.status === "running") { - if (!current || current.part.state.status === "streaming") + const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string" + const awaitingProvider = + current?.part.name === "websearch" && + current.part.state.status === "running" && + typeof current.part.state.metadata.provider !== "string" + if (ready && (!current || current.part.state.status === "streaming" || awaitingProvider)) setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory)) if (output) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory)) child.tools.set(key, { part }) diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index fede3d8821e..438993cf545 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -120,6 +120,7 @@ type ToolState = { part: SessionMessageAssistantTool output: string version: number + started: boolean } type State = { @@ -609,7 +610,7 @@ export async function createSessionTransport(input: StreamInput): Promise { await transport.close() }) + test("waits for the attempted web search provider before rendering its title", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + footer: ui.api, + }) + events.push({ + id: "evt_websearch_input", + created: 1, + type: "session.tool.input.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_websearch", + id: "call_websearch", + name: "websearch", + }, + }) + events.push({ + id: "evt_websearch_called", + created: 2, + type: "session.tool.called", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_websearch", + id: "call_websearch", + input: { query: "effect" }, + executed: true, + }, + }) + await Bun.sleep(0) + expect(ui.commits.filter((item) => item.part?.id === "call_websearch")).toEqual([]) + + events.push({ + id: "evt_websearch_progress", + created: 3, + type: "session.tool.progress", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_websearch", + id: "call_websearch", + metadata: { provider: "exa" }, + }, + }) + events.push({ + id: "evt_websearch_failed", + created: 4, + type: "session.tool.failed", + durable: durable("ses_1", 2, 2), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_websearch", + id: "call_websearch", + error: { type: "tool.execution", message: "Web search request failed (HTTP 403)" }, + metadata: { provider: "exa" }, + executed: true, + }, + }) + await Bun.sleep(0) + + const commits = ui.commits.filter((item) => item.part?.id === "call_websearch") + expect(commits.map((item) => item.phase)).toEqual(["start", "final"]) + const start = commits[0] + if (!start) throw new Error("Expected web search start commit") + expect(entryBody(start)).toEqual({ type: "text", content: '◈ Exa Web Search "effect"' }) + await transport.close() + }) + test("falls back to the default model when selecting a variant on a fresh session", async () => { const events = feed() events.push(connected())