Selaa lähdekoodia

fix(tui): restore web search failure details (#41545)

Shoubhit Dash 4 päivää sitten
vanhempi
sitoutus
5e16a5dc66

+ 1 - 1
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),

+ 7 - 4
packages/core/src/session/runner/publish-llm-event.ts

@@ -92,8 +92,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, 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<Bus.Interface, "publish">, 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<Bus.Interface, "publish">, inp
       assistantMessageID: tool.assistantMessageID,
       id,
       error,
-      ...failureSnapshot(tool),
+      ...failureSnapshot(tool, metadata),
       executed: tool.providerExecuted,
     })
     return true

+ 35 - 4
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<Effect.Success<ReturnType<typeof ctx.websearch.query>>, 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 },
+                    })
+                }
+              }),
             ),
         }),
       )

+ 13 - 0
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))

+ 56 - 0
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<void> | 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 },
+      )
+    }),
+  )
 })

+ 6 - 1
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 })

+ 7 - 4
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<Sessio
     }
     state.toolSources.set(key, part)
     if (part.state.status === "streaming") {
-      state.tools.set(key, { part, output: "", version: 0 })
+      state.tools.set(key, { part, output: "", version: 0, started: false })
       return
     }
     const current = state.tools.get(key)
@@ -618,16 +619,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     const version = current && !prefix ? current.version + 1 : (current?.version ?? 0)
     const delta = current && prefix ? output.slice(current.output.length) : output
     if (part.state.status === "running") {
-      if (render && (!current || current.part.state.status === "streaming"))
+      const started = current?.started === true
+      const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
+      if (render && !started && ready)
         write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)], {
           phase: "running",
           status: `running ${part.name}`,
         })
       if (render && delta) write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
-      state.tools.set(key, { part, output, version })
+      state.tools.set(key, { part, output, version, started: started || (render && ready) })
       return
     }
-    if (render && (!current || current.part.state.status === "streaming"))
+    if (render && !current?.started)
       write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)])
     state.finishedTools.add(key)
     state.tools.delete(key)

+ 75 - 0
packages/tui/test/mini/stream-v2.transport.test.ts

@@ -11,6 +11,7 @@ import {
   type PermissionRequest,
 } from "@opencode-ai/client/promise"
 import { createSessionTransport } from "../../src/mini/stream-v2.transport"
+import { entryBody } from "../../src/mini/entry.body"
 import type { StreamCommit } from "../../src/mini/types"
 import { createFooterApiFixture } from "./fixture/footer-api"
 import { canonicalToolPart } from "./fixture/tool-part"
@@ -2201,6 +2202,80 @@ describe("V2 mini transport", () => {
     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())