refactor: simplify websearch flow

This commit is contained in:
Shoubhit Dash
2026-07-21 19:01:15 +05:30
parent c0dc81ebab
commit dd01e4cd77
7 changed files with 42 additions and 59 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ import { Global } from "../global"
import { EffectFlock } from "../util/effect-flock"
export interface Interface {
readonly update: (path: JSONPath, value: unknown) => Effect.Effect<void, FSUtil.Error | EffectFlock.LockError>
readonly update: (jsonPath: JSONPath, value: unknown) => Effect.Effect<void, FSUtil.Error | EffectFlock.LockError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigGlobal") {}
@@ -26,11 +26,11 @@ const layer = Layer.effect(
update: Effect.fn("ConfigGlobal.update")(function* (jsonPath, value) {
yield* flock.withLock(
Effect.gen(function* () {
const existing = yield* Effect.filter(
const existingFiles = yield* Effect.filter(
["opencode.jsonc", "opencode.json"].map((name) => path.join(global.config, name)),
fs.existsSafe,
)
const filepath = existing[0] ?? path.join(global.config, "opencode.json")
const filepath = existingFiles[0] ?? path.join(global.config, "opencode.json")
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}"
const next = applyEdits(
text,
+9 -10
View File
@@ -204,10 +204,7 @@ export function fromPromise(plugin: Plugin) {
id: definition.id,
name: definition.name,
execute: (input, execution) =>
Effect.tryPromise({
try: (signal) => definition.execute(input, { ...execution, signal }),
catch: (cause) => cause,
}),
attempt((signal) => definition.execute(input, { ...execution, signal })),
}),
),
},
@@ -294,25 +291,23 @@ function adaptIntegration(definition: IntegrationDefinition) {
return {
...methodInfo,
authorize: (inputs: Parameters<typeof authorize>[0]) =>
Effect.tryPromise({ try: () => authorize(inputs), catch: (cause) => cause }).pipe(
attempt(() => authorize(inputs)).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: Effect.tryPromise({ try: () => authorization.callback, catch: (cause) => cause }),
callback: attempt(() => authorization.callback),
}
}
return {
...authorization,
callback: (code: string) =>
Effect.tryPromise({ try: () => authorization.callback(code), catch: (cause) => cause }),
callback: (code: string) => attempt(() => authorization.callback(code)),
}
}),
),
...(refresh
? {
refresh: (credential: Parameters<typeof refresh>[0]) =>
Effect.tryPromise({ try: () => refresh(credential), catch: (cause) => cause }),
refresh: (credential: Parameters<typeof refresh>[0]) => attempt(() => refresh(credential)),
}
: {}),
}
@@ -320,6 +315,10 @@ function adaptIntegration(definition: IntegrationDefinition) {
}
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
return Model.Ref.make({
id: Model.ID.make(input.id),
+16 -18
View File
@@ -33,7 +33,7 @@ export type Result = WebSearch.Result
export interface ProviderImplementation extends Provider {
readonly execute: (
input: WebSearch.ProviderInput,
input: ProviderInput,
context: { readonly sessionID?: string },
) => Effect.Effect<ProviderOutput, unknown>
}
@@ -43,9 +43,12 @@ export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequi
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("WebSearch.ProviderNotFound", {
providerID: ID,
}) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WebSearch.ProviderNotFound",
{
providerID: ID,
},
) {}
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("WebSearch.Cancelled", {}) {}
@@ -54,20 +57,14 @@ export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSe
cause: Schema.Defect(),
}) {}
export type Error =
| ProviderRequiredError
| ProviderNotFoundError
| CancelledError
| RequestError
export type Error = ProviderRequiredError | ProviderNotFoundError | CancelledError | RequestError
export interface QueryInput extends Input {
readonly sessionID?: string
}
export interface Interface {
readonly register: (
provider: ProviderImplementation,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly register: (provider: ProviderImplementation) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly list: () => Effect.Effect<readonly Provider[]>
readonly selected: () => Effect.Effect<ID | undefined>
readonly select: (providerID: ID) => Effect.Effect<void, ProviderNotFoundError>
@@ -110,10 +107,12 @@ const layer = Layer.effect(
}
const globalProviderID = Effect.fn("WebSearch.globalProviderID")(function* () {
const entries = (yield* config.entries()).filter(
(entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === globalConfigPath,
)
return Config.latest(entries, "websearch")?.provider
return Config.latest(
(yield* config.entries()).filter(
(entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === globalConfigPath,
),
"websearch",
)?.provider
})
const selected = Effect.fn("WebSearch.selected")(function* () {
@@ -214,8 +213,7 @@ const layer = Layer.effect(
}),
selected,
select: Effect.fn("WebSearch.select")(function* (providerID) {
const provider = state.get().providers.get(providerID)
if (!provider) return yield* new ProviderNotFoundError({ providerID })
if (!state.get().providers.has(providerID)) return yield* new ProviderNotFoundError({ providerID })
yield* saveProvider(providerID)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
+7 -7
View File
@@ -350,20 +350,20 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w
function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) {
const integrationID = Integration.ID.make(definition.id)
draft.update(integrationID, (integration) => (integration.name = definition.name))
for (const item of definition.methods ?? []) {
if (item.type === "env") {
draft.method.update(methodImplementation({ integrationID: definition.id, method: item }))
for (const method of definition.methods ?? []) {
if (method.type === "env") {
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
continue
}
if (item.type === "key") {
draft.method.update(methodImplementation({ integrationID: definition.id, method: item }))
if (method.type === "key") {
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
continue
}
const { authorize, refresh, credentialLabel, ...method } = item
const { authorize, refresh, credentialLabel, ...info } = method
draft.method.update(
methodImplementation({
integrationID: definition.id,
method,
method: info,
authorize,
...(refresh ? { refresh } : {}),
...(credentialLabel ? { label: credentialLabel } : {}),
+5 -12
View File
@@ -11,18 +11,18 @@ import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "../lib/effect"
export interface WebSearchRequest {
interface WebSearchRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
export const requests: WebSearchRequest[] = []
export const response = { body: "" }
let responseBody = ""
export function resetWebSearchFixture(body: string) {
requests.length = 0
response.body = body
responseBody = body
}
const http = Layer.succeed(
@@ -35,7 +35,7 @@ const http = Layer.succeed(
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(response.body, { status: 200 }))
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
}),
),
)
@@ -43,14 +43,7 @@ const http = Layer.succeed(
export const webSearchIntegrationTest = testEffect(
Layer.merge(
AppNodeBuilder.build(
LayerNode.group([
Integration.node,
Credential.node,
EventV2.node,
Form.node,
ConfigGlobal.node,
WebSearch.node,
]),
LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node, WebSearch.node]),
[
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
[
@@ -97,11 +97,7 @@ export function DialogIntegration(
? () => <text fg={themeV2.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (props.connectionOnly) {
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
return selectMethod(integration, methods, dialog, props.onConnected)
}
if (provider) return manageWebSearch(provider, dialog, integration, methods)
if (!props.connectionOnly && provider) return manageWebSearch(provider, dialog, integration, methods)
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
return selectMethod(integration, methods, dialog, props.onConnected)
},
+1 -4
View File
@@ -36,7 +36,6 @@ import { useClient } from "./client"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
type Data = Plugin.Context["data"]
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
@@ -58,7 +57,7 @@ type LocationData = {
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
websearch?: WebSearchProvider[]
websearchSelected?: string | null
websearchSelected?: WebSearchProvider["id"] | null
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellInfo>
@@ -876,8 +875,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
break
case "config.updated":
void result.location.websearch.refresh(event.location)
break
case "websearch.updated":
void result.location.websearch.refresh(event.location)
break