mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc85350950 | |||
| 5e16a5dc66 |
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user