Compare commits

...

2 Commits

Author SHA1 Message Date
Shoubhit Dash cc85350950 feat(tui): delay background tool hint 2026-08-10 20:45:36 +05:30
Shoubhit Dash 5e16a5dc66 fix(tui): restore web search failure details (#41545) 2026-08-10 19:24:59 +05:30
10 changed files with 233 additions and 28 deletions
+1 -1
View File
@@ -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),
@@ -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
View File
@@ -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 },
})
}
}),
),
}),
)
@@ -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
View File
@@ -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 },
)
}),
)
})
@@ -64,6 +64,7 @@ export type PromptProps = {
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element
runningHint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
@@ -1658,6 +1659,7 @@ export function Prompt(props: PromptProps) {
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
{props.runningHint}
</box>
</Match>
<Match when={move.progress()}>
+6 -1
View File
@@ -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
View File
@@ -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)
+31 -14
View File
@@ -114,6 +114,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
const BACKGROUND_TOOL_HINT_DELAY = 3_000
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
@@ -1056,7 +1057,6 @@ export function Session() {
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
@@ -1112,6 +1112,7 @@ export function Session() {
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID}
runningHint={<BackgroundToolHint messages={messages()} />}
/>
</Match>
</Switch>
@@ -1326,26 +1327,42 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const theme = useTheme()
const shortcut = Keymap.useShortcut("session.background")
const visible = createMemo(() => {
const tool = createMemo(() => {
const current = props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
)
return (
current?.content.some((part) => {
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
}) ?? false
)
return current?.content.find((part): part is SessionMessageAssistantTool => {
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
})
})
const toolID = () => tool()?.id
const toolStartedAt = () => {
const current = tool()
if (!current) return
return current.time.ran ?? current.time.created
}
const [visible, setVisible] = createSignal(false)
createEffect(
on([toolID, toolStartedAt], ([id, startedAt]) => {
setVisible(false)
if (!id || startedAt === undefined) return
const remaining = Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - startedAt))
if (remaining === 0) {
setVisible(true)
return
}
const timer = setTimeout(() => setVisible(true), remaining)
onCleanup(() => clearTimeout(timer))
}),
)
return (
<Show when={visible() && shortcut()}>
{(value) => (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<text fg={theme.text.subdued}>
Press <span style={{ fg: theme.text.default }}>{value()}</span> to move running work to the background
</text>
</box>
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<span style={{ fg: theme.text.default }}>{value()}</span> background
</text>
)}
</Show>
)
@@ -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())