mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 05:35:46 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44a898f3ee | |||
| 77fce8b24c |
@@ -1,9 +1,7 @@
|
||||
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
|
||||
import type { ModelApi, ProviderApi } from "./api/api.js"
|
||||
|
||||
export type * from "./api/api.js"
|
||||
|
||||
export type WebSearchApi<E = never> = WebsearchApi<E>
|
||||
|
||||
export interface CatalogApi<E = never> {
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
|
||||
@@ -923,40 +923,6 @@ export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
type Endpoint26_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.get"]>[0]
|
||||
export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
|
||||
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.get"]>>
|
||||
export type WebsearchProviderGetOperation<E = never> = (
|
||||
input?: Endpoint26_0Input,
|
||||
) => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
type Endpoint26_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
|
||||
export type Endpoint26_1Input = {
|
||||
readonly location?: Endpoint26_1Request["query"]["location"]
|
||||
readonly providerID: Endpoint26_1Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint26_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.select"]>>
|
||||
export type WebsearchProviderSelectOperation<E = never> = (
|
||||
input: Endpoint26_1Input,
|
||||
) => Effect.Effect<Endpoint26_1Output, E>
|
||||
|
||||
type Endpoint26_2Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
export type Endpoint26_2Input = {
|
||||
readonly location?: Endpoint26_2Request["query"]["location"]
|
||||
readonly query: Endpoint26_2Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint26_2Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint26_2Input) => Effect.Effect<Endpoint26_2Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly provider: {
|
||||
readonly get: WebsearchProviderGetOperation<E>
|
||||
readonly select: WebsearchProviderSelectOperation<E>
|
||||
}
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly location: LocationApi<E>
|
||||
@@ -984,5 +950,4 @@ export interface AppApi<E = never> {
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
}
|
||||
|
||||
@@ -1099,39 +1099,6 @@ const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
||||
})
|
||||
|
||||
type Endpoint26_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.get"]>[0]
|
||||
type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
|
||||
const Endpoint26_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint26_0Input) =>
|
||||
raw["websearch.provider.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint26_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
|
||||
type Endpoint26_1Input = {
|
||||
readonly location?: Endpoint26_1Request["query"]["location"]
|
||||
readonly providerID: Endpoint26_1Request["payload"]["providerID"]
|
||||
}
|
||||
const Endpoint26_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint26_1Input) =>
|
||||
raw["websearch.provider.select"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint26_2Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
type Endpoint26_2Input = {
|
||||
readonly location?: Endpoint26_2Request["query"]["location"]
|
||||
readonly query: Endpoint26_2Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
|
||||
}
|
||||
const Endpoint26_2 = (raw: RawClient["server.websearch"]) => (input: Endpoint26_2Input) =>
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.websearch"]) => ({
|
||||
provider: { get: Endpoint26_0(raw), select: Endpoint26_1(raw) },
|
||||
query: Endpoint26_2(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
location: adaptGroup1(raw["server.location"]),
|
||||
@@ -1159,7 +1126,6 @@ const adaptClient = (raw: RawClient) => ({
|
||||
projectCopy: adaptGroup23(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
debug: adaptGroup25(raw["server.debug"]),
|
||||
websearch: adaptGroup26(raw["server.websearch"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -14,7 +14,6 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
@@ -37,7 +36,6 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
PluginApi as EffectPluginApi,
|
||||
ProviderApi as EffectProviderApi,
|
||||
ReferenceApi as EffectReferenceApi,
|
||||
WebsearchApi,
|
||||
SessionApi as EffectSessionApi,
|
||||
SkillApi as EffectSkillApi,
|
||||
} from "../effect/api/api.js"
|
||||
@@ -37,7 +36,6 @@ export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
||||
export type WebSearchApi = PromisifyApi<WebsearchApi<unknown>>
|
||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
||||
|
||||
|
||||
@@ -184,12 +184,6 @@ import type {
|
||||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
WebsearchProviderGetInput,
|
||||
WebsearchProviderGetOutput,
|
||||
WebsearchProviderSelectInput,
|
||||
WebsearchProviderSelectOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1566,48 +1560,6 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
provider: {
|
||||
get: (input?: WebsearchProviderGetInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProviderGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/websearch/provider`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
select: (input: WebsearchProviderSelectInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProviderSelectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/websearch/provider`,
|
||||
query: { location: input["location"] },
|
||||
body: { providerID: input["providerID"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchQueryOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/websearch`,
|
||||
query: { location: input["location"] },
|
||||
body: { query: input["query"], providerID: input["providerID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2306,7 +2306,6 @@ export type IntegrationListOutput = {
|
||||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly websearch?: { readonly connection: "optional" | "required" }
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
@@ -2359,7 +2358,6 @@ export type IntegrationGetOutput = {
|
||||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly websearch?: { readonly connection: "optional" | "required" }
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
@@ -2702,14 +2700,6 @@ export type FormRequestListOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
@@ -2822,14 +2812,6 @@ export type FormListOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
>
|
||||
}["data"]
|
||||
|
||||
@@ -3526,14 +3508,6 @@ export type FormCreateOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type FormGetInput = {
|
||||
@@ -3648,14 +3622,6 @@ export type FormGetOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type FormStateInput = {
|
||||
@@ -5475,14 +5441,6 @@ export type EventSubscribeOutput =
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -6278,44 +6236,3 @@ export type DebugLocationEvictInput = {
|
||||
}
|
||||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type WebsearchProviderGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WebsearchProviderGetOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: string | null
|
||||
}
|
||||
|
||||
export type WebsearchProviderSelectInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly providerID: { readonly providerID: string }["providerID"]
|
||||
}
|
||||
|
||||
export type WebsearchProviderSelectOutput = void
|
||||
|
||||
export type WebsearchQueryInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
||||
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
||||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: { readonly providerID: string; readonly text: string; readonly metadata?: JsonValue }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
|
||||
@@ -31,7 +31,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
@@ -39,8 +38,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"])
|
||||
expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
|
||||
expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["provider", "query"])
|
||||
expect(Object.keys(client.websearch.provider)).toEqual(["get", "select"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
@@ -48,56 +45,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: { providerID: "exa", text: "result", metadata: { requestID: "req_test" } },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.websearch.query({
|
||||
query: "opencode",
|
||||
providerID: "exa",
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } })
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("websearch provider methods use the public HTTP contract", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "POST") return new Response(null, { status: 204 })
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: "exa",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.websearch.provider.get({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" })
|
||||
await client.websearch.provider.select({ providerID: "parallel", location: { directory: "/tmp/project" } })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
|
||||
["POST", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({ providerID: "parallel" })
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * as Config from "./config"
|
||||
export * as ConfigGlobal from "./config/global"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
@@ -24,7 +23,6 @@ import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
@@ -104,9 +102,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export * as ConfigGlobal from "./global"
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { applyEdits, modify, type JSONPath } from "jsonc-parser"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { FSUtil } from "../fs-util"
|
||||
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>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigGlobal") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
|
||||
return Service.of({
|
||||
update: Effect.fn("ConfigGlobal.update")(function* (jsonPath, value) {
|
||||
yield* flock.withLock(
|
||||
Effect.gen(function* () {
|
||||
const existing = 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 text = (yield* fs.readFileStringSafe(filepath)) ?? "{}"
|
||||
const next = applyEdits(
|
||||
text,
|
||||
modify(text, jsonPath, value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
const temp = `${filepath}.${randomUUID()}.tmp`
|
||||
yield* fs.writeWithDirs(temp, next)
|
||||
yield* fs.rename(temp, filepath).pipe(Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)))
|
||||
}),
|
||||
"global-config",
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [EffectFlock.node, FSUtil.node, Global.node] })
|
||||
@@ -1,8 +0,0 @@
|
||||
export * as ConfigWebSearch from "./websearch"
|
||||
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: Integration.ID,
|
||||
}) {}
|
||||
@@ -46,7 +46,7 @@ const layer = Layer.effect(
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = path.resolve(location.directory) === path.resolve(os.homedir())
|
||||
|
||||
if (!home && location.vcs) {
|
||||
if (!home) {
|
||||
yield* watcher
|
||||
.subscribe({
|
||||
path: location.directory,
|
||||
|
||||
@@ -67,7 +67,6 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
||||
export type CreateInput =
|
||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.IntegrationInfo, "id"> & { readonly id?: ID })
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly id: ID
|
||||
@@ -139,9 +138,7 @@ export const layer = Layer.effect(
|
||||
const form: Info =
|
||||
input.mode === "form"
|
||||
? { ...base, mode: "form", fields: input.fields }
|
||||
: input.mode === "url"
|
||||
? { ...base, mode: "url", url: input.url }
|
||||
: { ...base, mode: "integration", integrationID: input.integrationID }
|
||||
: { ...base, mode: "url", url: input.url }
|
||||
const entry: Entry = {
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
@@ -231,9 +228,9 @@ export const locationLayer = layer
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
function validateAnswer(form: Info, answer: Answer) {
|
||||
if (form.mode !== "form") {
|
||||
if (form.mode === "url") {
|
||||
if (Object.keys(answer).length === 0) return
|
||||
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
|
||||
return "URL forms must be answered with an empty answer"
|
||||
}
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||
for (const key of Object.keys(answer)) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Credential } from "./credential"
|
||||
import { State } from "./state"
|
||||
import { EventV2 } from "./event"
|
||||
@@ -95,15 +94,6 @@ export interface EnvImplementation {
|
||||
|
||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
||||
|
||||
export interface WebSearchImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly connection: Integration.WebSearch["connection"]
|
||||
readonly execute: (
|
||||
input: WebSearch.Input,
|
||||
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
|
||||
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export const Attempt = Integration.Attempt
|
||||
export type Attempt = Integration.Attempt
|
||||
|
||||
@@ -129,7 +119,6 @@ type Entry = {
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
websearch?: Types.DeepMutable<WebSearchImplementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -146,11 +135,6 @@ export type Draft = {
|
||||
update: (implementation: Implementation) => void
|
||||
remove: (integrationID: ID, method: Method) => void
|
||||
}
|
||||
websearch: {
|
||||
list: () => readonly WebSearchImplementation[]
|
||||
update: (implementation: WebSearchImplementation) => void
|
||||
remove: (integrationID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
@@ -207,10 +191,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** Cancels an attempt and releases its resources. */
|
||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||
}
|
||||
readonly websearch: {
|
||||
readonly list: () => Effect.Effect<readonly WebSearchImplementation[]>
|
||||
readonly get: (integrationID: ID) => Effect.Effect<WebSearchImplementation | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
@@ -301,30 +281,6 @@ const layer = Layer.effect(
|
||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
list: () =>
|
||||
Array.from(draft.integrations.values()).flatMap((entry) =>
|
||||
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
|
||||
),
|
||||
update: (implementation) => {
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
current.websearch = implementation as Types.DeepMutable<WebSearchImplementation>
|
||||
},
|
||||
remove: (integrationID) => {
|
||||
const current = draft.integrations.get(integrationID)
|
||||
if (current) delete current.websearch
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
@@ -349,7 +305,6 @@ const layer = Layer.effect(
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
websearch: entry.websearch ? { connection: entry.websearch.connection } : undefined,
|
||||
connections,
|
||||
})
|
||||
|
||||
@@ -559,16 +514,6 @@ const layer = Layer.effect(
|
||||
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||
}),
|
||||
},
|
||||
websearch: {
|
||||
list: Effect.fn("Integration.websearch.list")(function* () {
|
||||
return Array.from(state.get().integrations.values()).flatMap((entry) =>
|
||||
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
|
||||
)
|
||||
}),
|
||||
get: Effect.fn("Integration.websearch.get")(function* (integrationID) {
|
||||
return state.get().integrations.get(integrationID)?.websearch as WebSearchImplementation | undefined
|
||||
}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -34,7 +34,6 @@ import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { WebSearch } from "./websearch"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
@@ -52,6 +51,7 @@ import { SessionInstructions } from "./session/instructions"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { WebSearchTool } from "./tool/websearch"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
@@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -100,7 +100,6 @@ const locationServiceNodes = [
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
AISDK.node,
|
||||
|
||||
@@ -65,7 +65,7 @@ export const Model = Schema.Struct({
|
||||
name: Schema.String,
|
||||
family: Schema.optional(Schema.String),
|
||||
release_date: Schema.String,
|
||||
attachment: Schema.Boolean,
|
||||
attachment: Schema.optional(Schema.Boolean),
|
||||
reasoning: Schema.Boolean,
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
temperature: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { IntegrationDefinition, IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
@@ -199,7 +197,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||
),
|
||||
},
|
||||
register: (definition) => integration.transform((draft) => registerIntegration(draft, definition)),
|
||||
transform: (callback) =>
|
||||
integration.transform((draft) => {
|
||||
callback({
|
||||
@@ -209,7 +206,72 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => draft.method.update(methodImplementation(input)),
|
||||
update: (input) => {
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) =>
|
||||
authorization.callback(code).pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh
|
||||
? {
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
refresh(value).pipe(
|
||||
Effect.map((next) =>
|
||||
Credential.OAuth.make({
|
||||
...next,
|
||||
methodID: Integration.MethodID.make(next.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
})
|
||||
return
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
@@ -321,75 +383,3 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) {
|
||||
const integrationID = Integration.ID.make(definition.id)
|
||||
draft.update(integrationID, (integration) => (integration.name = definition.name))
|
||||
for (const method of definition.methods ?? []) {
|
||||
if (method.type === "env") {
|
||||
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
|
||||
continue
|
||||
}
|
||||
if (method.type === "key") {
|
||||
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
|
||||
continue
|
||||
}
|
||||
const { authorize, refresh, credentialLabel, ...info } = method
|
||||
draft.method.update(
|
||||
methodImplementation({
|
||||
integrationID: definition.id,
|
||||
method: info,
|
||||
authorize,
|
||||
...(refresh ? { refresh } : {}),
|
||||
...(credentialLabel ? { label: credentialLabel } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (!definition.websearch) return
|
||||
draft.websearch.update({
|
||||
integrationID,
|
||||
connection: definition.websearch.connection,
|
||||
execute: definition.websearch.execute,
|
||||
})
|
||||
}
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(Effect.map(credential)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
}
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
}
|
||||
}
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
}
|
||||
}
|
||||
|
||||
function credential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
@@ -51,7 +50,6 @@ import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
@@ -78,13 +76,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const todo = yield* SessionTodo.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
Context.make(Catalog.Service, catalog),
|
||||
@@ -107,13 +105,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(SessionTodo.Service, todo),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -129,7 +127,6 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
|
||||
@@ -185,11 +185,12 @@ function applyModel(
|
||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||
draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined
|
||||
draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings
|
||||
draft.capabilities = {
|
||||
const capabilities = ModelV2.Capabilities.defaults({
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
output: [...(model.modalities?.output ?? [])],
|
||||
}
|
||||
input: model.modalities?.input ?? (model.attachment === false ? ["text"] : undefined),
|
||||
output: model.modalities?.output,
|
||||
})
|
||||
draft.capabilities = { ...capabilities, input: [...capabilities.input], output: [...capabilities.output] }
|
||||
mergeVariants(draft, input.variants ?? [])
|
||||
draft.time.released = released(model.release_date)
|
||||
draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as PluginPromise from "./promise"
|
||||
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { IntegrationDefinition } from "@opencode-ai/plugin/v2/integration"
|
||||
import { Effect, Scope, Stream } from "effect"
|
||||
|
||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||
@@ -88,7 +87,6 @@ export function fromPromise(plugin: PromisePlugin) {
|
||||
complete: (input) => run(host.integration.attempt.complete(input)),
|
||||
cancel: (input) => run(host.integration.attempt.cancel(input)),
|
||||
},
|
||||
register: (definition) => register(host.integration.register(adaptIntegration(definition))),
|
||||
transform: transform(host.integration),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
@@ -129,54 +127,3 @@ export function fromPromise(plugin: PromisePlugin) {
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function adaptIntegration(definition: IntegrationDefinition) {
|
||||
const { methods, websearch, ...definitionInfo } = definition
|
||||
return {
|
||||
...definitionInfo,
|
||||
methods: methods?.map((method) => {
|
||||
if (method.type !== "oauth") return method
|
||||
const { authorize, refresh, ...methodInfo } = method
|
||||
return {
|
||||
...methodInfo,
|
||||
authorize: (inputs: Parameters<typeof authorize>[0]) =>
|
||||
Effect.tryPromise({ try: () => authorize(inputs), catch: (cause) => cause }).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: Effect.tryPromise({ try: () => authorization.callback, catch: (cause) => cause }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) =>
|
||||
Effect.tryPromise({ try: () => authorization.callback(code), catch: (cause) => cause }),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh
|
||||
? {
|
||||
refresh: (credential: Parameters<typeof refresh>[0]) =>
|
||||
Effect.tryPromise({ try: () => refresh(credential), catch: (cause) => cause }),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}),
|
||||
...(websearch
|
||||
? {
|
||||
websearch: {
|
||||
connection: websearch.connection,
|
||||
execute: (
|
||||
input: Parameters<typeof websearch.execute>[0],
|
||||
execution: Omit<Parameters<typeof websearch.execute>[1], "signal">,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => websearch.execute(input, { ...execution, signal }),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
export * as WebSearchExa from "./exa"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const Input = Schema.Struct({
|
||||
query: Schema.String,
|
||||
numResults: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
content: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.exa",
|
||||
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.register({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
methods: [
|
||||
{ type: "key", label: "API key (optional)" },
|
||||
{ type: "env", names: ["EXA_API_KEY"] },
|
||||
],
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: (input, context) => {
|
||||
const url = new URL(endpoint)
|
||||
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
|
||||
return WebSearchMcp.call(
|
||||
http,
|
||||
url.toString(),
|
||||
"web_search_exa",
|
||||
{ input: Input, output: Output },
|
||||
{ query: input.query, numResults: 8 },
|
||||
).pipe(
|
||||
Effect.map((result) => {
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return {
|
||||
text: content?.text ?? "",
|
||||
...(content?._meta ? { metadata: content._meta } : {}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
@@ -1,69 +0,0 @@
|
||||
export * as WebSearchMcp from "./mcp"
|
||||
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { collectBoundedResponseBody } from "../../tool/http-body"
|
||||
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
|
||||
const parse = (payload: string) => {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
|
||||
return decode(trimmed).pipe(Effect.map((response) => response.result))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parse(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parse(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(Request(schema.input))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"), schema.output)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
export * as WebSearchParallel from "./parallel"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const Input = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Metadata = Schema.Struct({
|
||||
search_id: Schema.String,
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
excerpts: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
warnings: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
|
||||
message: Schema.String,
|
||||
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
usage: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Int,
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const Output = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
structuredContent: Metadata,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.parallel",
|
||||
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.register({
|
||||
id: "parallel",
|
||||
name: "Parallel",
|
||||
methods: [
|
||||
{ type: "key", label: "API key (optional)" },
|
||||
{ type: "env", names: ["PARALLEL_API_KEY"] },
|
||||
],
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: (input, context) =>
|
||||
WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
{ input: Input, output: Output },
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
...(context.sessionID ? { session_id: context.sessionID } : {}),
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((result) => {
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return {
|
||||
text: content?.text ?? "",
|
||||
...(result ? { metadata: result.structuredContent } : {}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -237,9 +237,10 @@ const layer = Layer.effect(
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep
|
||||
? undefined
|
||||
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
|
||||
const toolMaterialization =
|
||||
isLastStep || !resolved.capabilities.tools
|
||||
? undefined
|
||||
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -251,7 +252,7 @@ const layer = Layer.effect(
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref, providerMetadataKey),
|
||||
...toLLMMessages(context, resolved.ref, providerMetadataKey, resolved.capabilities),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface Resolved {
|
||||
readonly model: Model
|
||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||
readonly ref: ModelV2.Ref
|
||||
/** Capabilities of the selected catalog model. */
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
/** Catalog pricing in dollars per million tokens. */
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
@@ -96,13 +98,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
|
||||
export const resolved = (
|
||||
model: Model,
|
||||
variant?: ModelV2.VariantID,
|
||||
cost: ModelV2.Info["cost"] = [],
|
||||
capabilities = ModelV2.Capabilities.defaults(),
|
||||
): Resolved => ({
|
||||
model,
|
||||
ref: ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make(model.id),
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(variant === undefined ? {} : { variant }),
|
||||
}),
|
||||
capabilities,
|
||||
cost,
|
||||
})
|
||||
|
||||
@@ -344,6 +352,7 @@ const layer = Layer.effect(
|
||||
providerID: selected.providerID,
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
}),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -11,8 +11,6 @@ import type { ModelV2 } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
const media = (file: FileAttachment): ContentPart => ({
|
||||
type: "media",
|
||||
mediaType: file.mime,
|
||||
@@ -21,6 +19,23 @@ const media = (file: FileAttachment): ContentPart => ({
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const modality = (mime: string) => {
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
if (mime.startsWith("audio/")) return "audio"
|
||||
if (mime.startsWith("video/")) return "video"
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const attachment = (file: FileAttachment, capabilities?: ModelV2.Capabilities): ContentPart => {
|
||||
const type = modality(file.mime)
|
||||
if (!type || (capabilities?.input ?? ["text", "image"]).includes(type)) return media(file)
|
||||
return {
|
||||
type: "text",
|
||||
text: `ERROR: Cannot read ${file.name ? `"${file.name}"` : type} (this model does not support ${type} input). Inform the user.`,
|
||||
}
|
||||
}
|
||||
|
||||
const textAttachment = (file: FileAttachment) =>
|
||||
Message.make({
|
||||
role: "user",
|
||||
@@ -168,7 +183,12 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
|
||||
function toLLMMessage(
|
||||
message: SessionMessage.Info,
|
||||
model: ModelV2.Ref,
|
||||
providerMetadataKey: string,
|
||||
capabilities?: ModelV2.Capabilities,
|
||||
): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
@@ -183,7 +203,9 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: message.text },
|
||||
...files.filter((file) => imageMimes.has(file.mime)).map(media),
|
||||
...files
|
||||
.filter((file) => file.mime !== "text/plain" && file.mime !== "application/x-directory")
|
||||
.map((file) => attachment(file, capabilities)),
|
||||
],
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
@@ -236,4 +258,5 @@ export const toLLMMessages = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
model: ModelV2.Ref,
|
||||
providerMetadataKey: string = model.providerID,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
|
||||
capabilities?: ModelV2.Capabilities,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, capabilities))
|
||||
|
||||
@@ -2,34 +2,197 @@ export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Integration } from "../integration"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Tool } from "./tool"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||
{
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
|
||||
|
||||
/** Isolates the retained product environment contract from the generic tool implementation. */
|
||||
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
ConfigService.of({
|
||||
provider:
|
||||
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
|
||||
? process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
: undefined,
|
||||
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
||||
}),
|
||||
)
|
||||
|
||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
override?: Provider,
|
||||
): Provider {
|
||||
if (override) return override
|
||||
if (flags.enableParallel) return "parallel"
|
||||
if (flags.enableExa) return "exa"
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const ExaArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
const ParallelArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
const exaUrl = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return EXA_URL
|
||||
const url = new URL(EXA_URL)
|
||||
url.searchParams.set("exaApiKey", apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const callMcp = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
provider: Integration.ID,
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
metadata: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const permission = yield* PermissionV2.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -40,28 +203,54 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: input,
|
||||
metadata: { ...input, provider },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const result = yield* websearch.query({ ...input, sessionID: context.sessionID })
|
||||
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
return {
|
||||
provider: result.providerID,
|
||||
text: result.text || NO_RESULTS,
|
||||
metadata: result.metadata,
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConfigMCPV1 } from "./mcp"
|
||||
import { ConfigPermissionV1 } from "./permission"
|
||||
import { ConfigProviderV1 } from "./provider"
|
||||
import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const keys = new Set([
|
||||
@@ -245,8 +246,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||
: []),
|
||||
]
|
||||
const capabilities =
|
||||
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
|
||||
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
|
||||
info.tool_call !== undefined ||
|
||||
info.attachment !== undefined ||
|
||||
info.modalities?.input !== undefined ||
|
||||
info.modalities?.output !== undefined
|
||||
? ModelV2.Capabilities.defaults({
|
||||
tools: info.tool_call,
|
||||
input: info.modalities?.input ?? (info.attachment === false ? ["text"] : undefined),
|
||||
output: info.modalities?.output,
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
modelID: info.id,
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
export * as WebSearch from "./websearch"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import path from "node:path"
|
||||
import { Config } from "./config"
|
||||
import { ConfigGlobal } from "./config/global"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { EventV2 } from "./event"
|
||||
import { Form } from "./form"
|
||||
import { Global } from "./global"
|
||||
import { Integration } from "./integration"
|
||||
import { truthy } from "./flag/flag"
|
||||
|
||||
export const Input = WebSearch.Input
|
||||
export type Input = WebSearch.Input
|
||||
|
||||
export const ProviderOutput = WebSearch.ProviderOutput
|
||||
export type ProviderOutput = WebSearch.ProviderOutput
|
||||
|
||||
export const Result = WebSearch.Result
|
||||
export type Result = WebSearch.Result
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"WebSearch.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("WebSearch.ProviderNotFound", {
|
||||
providerID: Integration.ID,
|
||||
}) {}
|
||||
|
||||
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
|
||||
"WebSearch.ConnectionRequired",
|
||||
{ providerID: Integration.ID },
|
||||
) {}
|
||||
|
||||
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("WebSearch.Cancelled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
|
||||
providerID: Integration.ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| ProviderRequiredError
|
||||
| ProviderNotFoundError
|
||||
| ConnectionRequiredError
|
||||
| CancelledError
|
||||
| RequestError
|
||||
|
||||
export interface QueryInput extends Input {
|
||||
readonly sessionID?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly selected: () => Effect.Effect<Integration.ID | undefined>
|
||||
readonly select: (providerID: Integration.ID) => Effect.Effect<void, ProviderNotFoundError>
|
||||
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const configGlobal = yield* ConfigGlobal.Service
|
||||
const events = yield* EventV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const global = yield* Global.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const onboarding = Semaphore.makeUnsafe(1)
|
||||
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
|
||||
const globalConfigPath = path.resolve(global.config)
|
||||
let pendingProviderID: Integration.ID | undefined
|
||||
|
||||
const requireProvider = (
|
||||
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
|
||||
providerID: Integration.ID,
|
||||
) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const selected = Effect.fn("WebSearch.selected")(function* () {
|
||||
return pendingProviderID ?? (yield* globalProviderID())
|
||||
})
|
||||
|
||||
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: Integration.ID) {
|
||||
pendingProviderID = providerID
|
||||
yield* configGlobal.update(["websearch"], new ConfigWebSearch.Info({ provider: providerID })).pipe(
|
||||
Effect.tapError(() => Effect.sync(() => (pendingProviderID = undefined))),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
|
||||
yield* events.subscribe(ConfigSchema.Event.Updated).pipe(
|
||||
Stream.runForEach(() =>
|
||||
globalProviderID().pipe(
|
||||
Effect.tap((providerID) =>
|
||||
Effect.sync(() => {
|
||||
if (providerID === pendingProviderID) pendingProviderID = undefined
|
||||
}),
|
||||
),
|
||||
Effect.ignore,
|
||||
),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const ask = Effect.fn("WebSearch.ask")(function* (
|
||||
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
|
||||
sessionID: string,
|
||||
) {
|
||||
if (providers.size === 0) return yield* new ProviderRequiredError()
|
||||
const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "This becomes your default and can be changed later from Connect integration.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: Array.from(providers.values())
|
||||
.flatMap((provider) => {
|
||||
const info = infos.get(provider.integrationID)
|
||||
if (!info) return []
|
||||
const disconnected = provider.connection === "optional" ? "Keyless available" : "Connection required"
|
||||
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
|
||||
})
|
||||
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
|
||||
.map(({ info, description }) => ({
|
||||
value: info.id,
|
||||
label: info.name,
|
||||
description,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const answer = state.answer.provider
|
||||
if (typeof answer !== "string") return yield* new ProviderRequiredError()
|
||||
return yield* requireProvider(providers, Integration.ID.make(answer))
|
||||
})
|
||||
|
||||
const connect = Effect.fn("WebSearch.connect")(function* (
|
||||
provider: Integration.WebSearchImplementation,
|
||||
sessionID?: string,
|
||||
) {
|
||||
const active = yield* integrations.connection.active(provider.integrationID)
|
||||
if (active || provider.connection === "optional") return active
|
||||
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: `Connect ${provider.integrationID}`,
|
||||
metadata: { kind: "integration.connection" },
|
||||
mode: "integration",
|
||||
integrationID: provider.integrationID,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const connected = yield* integrations.connection.active(provider.integrationID)
|
||||
if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
return connected
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: QueryInput) {
|
||||
const providers = new Map(
|
||||
(yield* integrations.websearch.list()).map((provider) => [provider.integrationID, provider]),
|
||||
)
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const configuredProviderID = Config.latest(yield* config.entries(), "websearch")?.provider
|
||||
if (configuredProviderID) return yield* requireProvider(providers, configuredProviderID)
|
||||
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
||||
return yield* requireProvider(providers, Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER))
|
||||
}
|
||||
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
|
||||
return yield* requireProvider(providers, Integration.ID.make("parallel"))
|
||||
}
|
||||
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
|
||||
return yield* requireProvider(providers, Integration.ID.make("exa"))
|
||||
}
|
||||
const providerID = yield* selected()
|
||||
const provider = providerID ? providers.get(providerID) : undefined
|
||||
if (provider) return provider
|
||||
const sessionID = input.sessionID
|
||||
if (!sessionID) return yield* new ProviderRequiredError()
|
||||
return yield* onboarding.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* selected()
|
||||
const selectedProvider = current ? providers.get(current) : undefined
|
||||
if (selectedProvider) return selectedProvider
|
||||
const provider = yield* ask(providers, sessionID)
|
||||
yield* connect(provider, sessionID)
|
||||
yield* saveProvider(provider.integrationID)
|
||||
return provider
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
selected,
|
||||
select: Effect.fn("WebSearch.select")(function* (providerID) {
|
||||
const provider = yield* integrations.websearch.get(providerID)
|
||||
if (!provider) return yield* new ProviderNotFoundError({ providerID })
|
||||
yield* saveProvider(providerID)
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const connection = yield* connect(provider, input.sessionID)
|
||||
const credential = connection
|
||||
? yield* integrations.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
||||
: undefined
|
||||
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
||||
Effect.flatMap(decodeOutput),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
||||
)
|
||||
return new Result({ providerID: provider.integrationID, ...output })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node, Integration.node],
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigGlobal } from "@opencode-ai/core/config/global"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
@@ -23,7 +22,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { parse } from "jsonc-parser"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
|
||||
@@ -60,37 +58,6 @@ const provider = {
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("updates the global JSONC config without removing comments", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const file = path.join(global, "opencode.jsonc")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.writeFile(file, `// user config\n{\n "username": "tester"\n}\n`)
|
||||
})
|
||||
|
||||
const config = yield* ConfigGlobal.Service
|
||||
yield* config.update(["websearch"], { provider: "exa" })
|
||||
|
||||
const text = yield* Effect.promise(() => Bun.file(file).text())
|
||||
expect(text).toContain("// user config")
|
||||
expect(parse(text)).toEqual({ username: "tester", websearch: { provider: "exa" } })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([ConfigGlobal.node]), [
|
||||
[Global.node, Global.layerWith({ config: path.join(tmp.path, "global") })],
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -713,9 +680,17 @@ describe("Config", () => {
|
||||
options: { apiKey: "secret" },
|
||||
models: {
|
||||
model: {
|
||||
attachment: true,
|
||||
options: { reasoningEffort: "high" },
|
||||
variants: { fast: { temperature: 0.2 } },
|
||||
},
|
||||
text: {
|
||||
attachment: false,
|
||||
},
|
||||
audio: {
|
||||
attachment: true,
|
||||
modalities: { input: ["audio"], output: ["audio"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
openai: {
|
||||
@@ -791,9 +766,16 @@ describe("Config", () => {
|
||||
settings: { apiKey: "secret" },
|
||||
models: {
|
||||
model: {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
settings: { reasoningEffort: "high" },
|
||||
variants: [{ id: "fast", settings: { temperature: 0.2 } }],
|
||||
},
|
||||
text: {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
},
|
||||
audio: {
|
||||
capabilities: { tools: true, input: ["audio"], output: ["audio"] },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(documents[0]?.info.providers?.openai).toMatchObject({
|
||||
|
||||
@@ -237,6 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
const provider = required(yield* catalog.provider.get(providerID))
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
const defaultModel = required(yield* catalog.model.get(providerID, ModelV2.ID.make("default")))
|
||||
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
@@ -252,6 +253,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(defaultModel.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -60,27 +59,6 @@ describe("Form", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses an empty reply to complete an integration form", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const created = yield* service.create({
|
||||
sessionID: "ses_test",
|
||||
mode: "integration",
|
||||
integrationID: Integration.ID.make("exa"),
|
||||
})
|
||||
|
||||
const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip)
|
||||
expect(invalid).toEqual(
|
||||
new Form.InvalidAnswerError({
|
||||
id: created.id,
|
||||
message: "Integration forms must be answered with an empty answer",
|
||||
}),
|
||||
)
|
||||
yield* service.reply({ id: created.id, answer: {} })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("gates required fields and rejects inactive answers via when", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
||||
@@ -155,10 +155,6 @@ function resourceMcpLayer(url: string) {
|
||||
complete: unusedIntegration,
|
||||
cancel: unusedIntegration,
|
||||
},
|
||||
websearch: {
|
||||
list: unusedIntegration,
|
||||
get: unusedIntegration,
|
||||
},
|
||||
}),
|
||||
Layer.mock(Credential.Service, {}),
|
||||
),
|
||||
|
||||
@@ -165,6 +165,21 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows models.dev entries without legacy attachment metadata", () =>
|
||||
Effect.sync(() => {
|
||||
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
|
||||
id: "no-attachment-model",
|
||||
name: "No Attachment Model",
|
||||
release_date: "2026-01-01",
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 8192 },
|
||||
})
|
||||
|
||||
expect(result.attachment).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns providers from disk when cache file exists", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeCache(fixture)
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { IntegrationDefinition, IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
IntegrationEnvMethod,
|
||||
IntegrationKeyMethod,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type Overrides = Partial<Omit<Plugin.Context, "options">>
|
||||
type Overrides = Partial<Omit<PluginContext, "options">>
|
||||
|
||||
export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
export function host(overrides: Overrides = {}): PluginContext {
|
||||
return {
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
@@ -59,7 +53,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
complete: () => Effect.die("unused integration.attempt.complete"),
|
||||
cancel: () => Effect.die("unused integration.attempt.cancel"),
|
||||
},
|
||||
register: () => Effect.die("unused integration.register"),
|
||||
transform: () => Effect.die("unused integration.transform"),
|
||||
reload: () => Effect.die("unused integration.reload"),
|
||||
connection: {
|
||||
@@ -97,7 +90,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
}
|
||||
}
|
||||
|
||||
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
|
||||
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
return {
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
reload: agent.reload,
|
||||
@@ -122,7 +115,7 @@ export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
|
||||
}
|
||||
}
|
||||
|
||||
export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
|
||||
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
|
||||
return {
|
||||
provider: {
|
||||
list: () => Effect.die("unused catalog.provider.list"),
|
||||
@@ -194,7 +187,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
|
||||
}
|
||||
}
|
||||
|
||||
export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
|
||||
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
|
||||
return {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
@@ -215,7 +208,6 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||
),
|
||||
},
|
||||
register: (definition) => integration.transform((draft) => registerIntegration(draft, definition)),
|
||||
transform: (callback) =>
|
||||
integration.transform((draft) =>
|
||||
callback({
|
||||
@@ -228,7 +220,72 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
|
||||
update: (input) => draft.method.update(methodImplementation(input)),
|
||||
update: (input) => {
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) =>
|
||||
authorization.callback(code).pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh
|
||||
? {
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
refresh(value).pipe(
|
||||
Effect.map((next) =>
|
||||
Credential.OAuth.make({
|
||||
...next,
|
||||
methodID: Integration.MethodID.make(next.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, names: [...input.method.names] },
|
||||
})
|
||||
return
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: input.method,
|
||||
})
|
||||
},
|
||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||
},
|
||||
}),
|
||||
@@ -236,72 +293,6 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
continue
|
||||
}
|
||||
if (item.type === "key") {
|
||||
draft.method.update(methodImplementation({ integrationID: definition.id, method: item }))
|
||||
continue
|
||||
}
|
||||
const { authorize, refresh, credentialLabel, ...method } = item
|
||||
draft.method.update(
|
||||
methodImplementation({
|
||||
integrationID: definition.id,
|
||||
method,
|
||||
authorize,
|
||||
...(refresh ? { refresh } : {}),
|
||||
...(credentialLabel ? { label: credentialLabel } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (!definition.websearch) return
|
||||
draft.websearch.update({
|
||||
integrationID,
|
||||
connection: definition.websearch.connection,
|
||||
execute: definition.websearch.execute,
|
||||
})
|
||||
}
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return { ...authorization, callback: authorization.callback.pipe(Effect.map(oauthCredential)) }
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) => authorization.callback(code).pipe(Effect.map(oauthCredential)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(oauthCredential)) } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
}
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, names: [...input.method.names] },
|
||||
}
|
||||
}
|
||||
return { integrationID: Integration.ID.make(input.integrationID), method: input.method }
|
||||
}
|
||||
|
||||
function oauthCredential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
function method(value: Integration.Method) {
|
||||
if (value.type === "env") return { type: value.type, names: [...value.names] }
|
||||
if (value.type === "key") return { type: value.type, label: value.label }
|
||||
|
||||
@@ -85,6 +85,24 @@ describe("ModelsDevPlugin", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
default: {
|
||||
id: "default",
|
||||
name: "Default",
|
||||
release_date: "2026-01-01",
|
||||
reasoning: false,
|
||||
tool_call: false,
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
},
|
||||
explicit: {
|
||||
id: "explicit",
|
||||
name: "Explicit",
|
||||
release_date: "2026-01-01",
|
||||
attachment: true,
|
||||
reasoning: false,
|
||||
tool_call: false,
|
||||
modalities: { input: ["audio"], output: ["audio"] },
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, ModelsDev.Provider>),
|
||||
@@ -101,9 +119,14 @@ describe("ModelsDevPlugin", () => {
|
||||
const providerID = ProviderV2.ID.make("acme")
|
||||
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
|
||||
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
|
||||
const defaults = yield* catalog.model.get(providerID, ModelV2.ID.make("default"))
|
||||
const explicit = yield* catalog.model.get(providerID, ModelV2.ID.make("explicit"))
|
||||
|
||||
expect(base?.variants).toEqual([])
|
||||
expect(base?.body).toEqual({})
|
||||
expect(base?.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(defaults?.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] })
|
||||
expect(explicit?.capabilities).toEqual({ tools: false, input: ["audio"], output: ["audio"] })
|
||||
expect(fast).toMatchObject({
|
||||
id: "gpt-5.4-fast",
|
||||
modelID: "gpt-5.4",
|
||||
@@ -181,6 +204,9 @@ describe("ModelsDevPlugin", () => {
|
||||
connections: [],
|
||||
}),
|
||||
])
|
||||
expect(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"))).toMatchObject({
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
})
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
@@ -94,35 +93,4 @@ describe("fromPromise", () => {
|
||||
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise web search capability execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-websearch",
|
||||
setup: async (ctx) => {
|
||||
await ctx.integration.register({
|
||||
id: "promise-websearch",
|
||||
name: "Promise Web Search",
|
||||
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: async (input) => ({ text: `promise: ${input.query}` }),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
expect(yield* integrations.get(Integration.ID.make("promise-websearch"))).toMatchObject({
|
||||
name: "Promise Web Search",
|
||||
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
|
||||
})
|
||||
const provider = yield* integrations.websearch.get(Integration.ID.make("promise-websearch"))
|
||||
if (!provider) return yield* Effect.die("Expected promise web search provider")
|
||||
expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
export interface WebSearchRequest {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
export const requests: WebSearchRequest[] = []
|
||||
export const response = { body: "" }
|
||||
|
||||
export function resetWebSearchFixture(body: string) {
|
||||
requests.length = 0
|
||||
response.body = body
|
||||
}
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, new Response(response.body, { status: 200 }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const webSearchIntegrationTest = testEffect(
|
||||
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { host, integrationHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebSearchFixture(
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: "search results", _meta: { searchTime: 123 } }] },
|
||||
})}\n\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const it = webSearchIntegrationTest
|
||||
|
||||
describe("built-in web search integrations", () => {
|
||||
it.effect("registers and disposes an atomic web search integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const registration = yield* integrationHost(integrations).register({
|
||||
id: "test-websearch",
|
||||
name: "Test Web Search",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
websearch: {
|
||||
connection: "required",
|
||||
execute: (input) => Effect.succeed({ text: input.query }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toMatchObject({
|
||||
name: "Test Web Search",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
websearch: { connection: "required" },
|
||||
})
|
||||
yield* registration.dispose
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Exa with its MCP schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* WebSearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
|
||||
const info = yield* integrations.get(Integration.ID.make("exa"))
|
||||
expect(info).toMatchObject({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||
websearch: { connection: "optional" },
|
||||
})
|
||||
const provider = yield* integrations.websearch.get(Integration.ID.make("exa"))
|
||||
if (!provider) return yield* Effect.die("Expected Exa web search provider")
|
||||
expect(
|
||||
yield* provider.execute(
|
||||
{ query: "effect typescript" },
|
||||
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
|
||||
),
|
||||
).toEqual({ text: "search results", metadata: { searchTime: 123 } })
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: { query: "effect typescript", numResults: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
|
||||
Effect.gen(function* () {
|
||||
resetWebSearchFixture(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
content: [{ type: "text", text: "search results" }],
|
||||
structuredContent: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* WebSearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const provider = yield* integrations.websearch.get(Integration.ID.make("parallel"))
|
||||
if (!provider) return yield* Effect.die("Expected Parallel web search provider")
|
||||
|
||||
const output = yield* provider.execute(
|
||||
{ query: "effect layers" },
|
||||
{
|
||||
sessionID: "ses_parallel",
|
||||
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
|
||||
},
|
||||
)
|
||||
expect(output).toEqual({
|
||||
text: "search results",
|
||||
metadata: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
})
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchParallel.endpoint,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: {
|
||||
objective: "effect layers",
|
||||
search_queries: ["effect layers"],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(output)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -240,7 +240,7 @@ Recent work
|
||||
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
|
||||
})
|
||||
|
||||
test("uses materialized image data as provider media and drops unsupported attachments", () => {
|
||||
test("defaults missing model capabilities to text and image input", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
@@ -266,6 +266,113 @@ Recent work
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
{
|
||||
type: "text",
|
||||
text: 'ERROR: Cannot read "document.pdf" (this model does not support pdf input). Inform the user.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses explicit model input capabilities instead of attachment defaults", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-unsupported-pdf"),
|
||||
type: "user",
|
||||
text: "Inspect these files",
|
||||
files: [
|
||||
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }),
|
||||
FileAttachment.make({
|
||||
data: Base64.make("JVBERg=="),
|
||||
mime: "application/pdf",
|
||||
source: { type: "inline" },
|
||||
name: "document.pdf",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
model.providerID,
|
||||
{ tools: true, input: ["text", "pdf"], output: ["text"] },
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect these files" },
|
||||
{
|
||||
type: "text",
|
||||
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
|
||||
},
|
||||
{ type: "media", mediaType: "application/pdf", data: "JVBERg==", filename: "document.pdf" },
|
||||
])
|
||||
})
|
||||
|
||||
test("treats explicit empty input capabilities as authoritative", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-empty-capabilities"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data: Base64.make("AAECAw=="),
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
model.providerID,
|
||||
{ tools: false, input: [], output: [] },
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{
|
||||
type: "text",
|
||||
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies audio and video MIME families through explicit capabilities", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-media-capabilities"),
|
||||
type: "user",
|
||||
text: "Inspect this media",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data: Base64.make("AAECAw=="),
|
||||
mime: "audio/mpeg",
|
||||
source: { type: "inline" },
|
||||
name: "audio.mp3",
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data: Base64.make("AAECAw=="),
|
||||
mime: "video/webm",
|
||||
source: { type: "inline" },
|
||||
name: "video.webm",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
model.providerID,
|
||||
{ tools: false, input: ["text", "audio", "video"], output: ["text"] },
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this media" },
|
||||
{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==", filename: "audio.mp3" },
|
||||
{ type: "media", mediaType: "video/webm", data: "AAECAw==", filename: "video.webm" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -235,12 +235,15 @@ const echo = Layer.effectDiscard(
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
let modelResolveHook = Effect.void
|
||||
let currentModel = model
|
||||
let modelCapabilities = ModelV2.Capabilities.defaults()
|
||||
const models = SessionRunnerModel.layerWith((session) =>
|
||||
modelResolveHook.pipe(
|
||||
Effect.as(
|
||||
SessionRunnerModel.resolved(
|
||||
session.model?.id === "replacement" ? replacementModel : currentModel,
|
||||
session.model?.variant,
|
||||
[],
|
||||
modelCapabilities,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -412,6 +415,7 @@ const setup = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
currentModel = model
|
||||
modelCapabilities = ModelV2.Capabilities.defaults()
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
streamFailure = undefined
|
||||
@@ -787,6 +791,22 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not advertise tools to a model without tool capability", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
modelCapabilities = ModelV2.Capabilities.defaults({ tools: false })
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "No tools" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.tools).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries the first provider turn after system context becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -1,35 +1,104 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node],
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const queries: WebSearch.QueryInput[] = []
|
||||
let result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
const payload = (text: string) =>
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text }] },
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
describe("WebSearchTool provider selection", () => {
|
||||
test("rejects out-of-range numeric controls", () => {
|
||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
||||
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
||||
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
||||
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
||||
})
|
||||
test("selects a stable provider per session", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
|
||||
})
|
||||
|
||||
test("supports an explicit operational override", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
|
||||
"parallel",
|
||||
)
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
|
||||
})
|
||||
|
||||
test("prefers Parallel when both explicit flags are enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
|
||||
})
|
||||
|
||||
test("prefers Exa when only its explicit flag is enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
|
||||
})
|
||||
})
|
||||
|
||||
describe("WebSearchTool MCP response parser", () => {
|
||||
test("parses plain JSON-RPC responses", async () => {
|
||||
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
|
||||
})
|
||||
|
||||
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
|
||||
),
|
||||
).toBe("search results")
|
||||
})
|
||||
})
|
||||
|
||||
interface Request {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
const requests: Request[] = []
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let responseBody = payload("search results")
|
||||
let makeResponse = () => new Response(responseBody, { status: 200 })
|
||||
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
|
||||
|
||||
beforeEach(() => {
|
||||
responseBody = payload("search results")
|
||||
makeResponse = () => new Response(responseBody, { status: 200 })
|
||||
})
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, makeResponse())
|
||||
}),
|
||||
),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
@@ -41,29 +110,45 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
selected: () => Effect.succeed(undefined),
|
||||
select: () => Effect.die("unused"),
|
||||
query: (input) =>
|
||||
Effect.sync(() => {
|
||||
queries.push(input)
|
||||
return result
|
||||
}),
|
||||
const websearchConfig = Layer.succeed(
|
||||
WebSearchTool.ConfigService,
|
||||
WebSearchTool.ConfigService.of({
|
||||
get provider() {
|
||||
return config.provider
|
||||
},
|
||||
get enableExa() {
|
||||
return config.enableExa
|
||||
},
|
||||
get enableParallel() {
|
||||
return config.enableParallel
|
||||
},
|
||||
get exaApiKey() {
|
||||
return config.exaApiKey
|
||||
},
|
||||
get parallelApiKey() {
|
||||
return config.parallelApiKey
|
||||
},
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]), [
|
||||
[PermissionV2.node, permission],
|
||||
[WebSearch.node, websearch],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
[WebSearchTool.configNode, websearchConfig],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("asserts permission before delegating to WebSearch", () =>
|
||||
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
||||
@@ -73,58 +158,122 @@ describe("WebSearchTool registration", () => {
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-search",
|
||||
id: "call-exa",
|
||||
name: "websearch",
|
||||
input: { query: "effect typescript" },
|
||||
input: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: "search results" })
|
||||
).toEqual({ type: "text", value: "exa results" })
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "websearch",
|
||||
resources: ["effect typescript"],
|
||||
save: ["*"],
|
||||
metadata: { query: "effect typescript" },
|
||||
metadata: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
provider: "exa",
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(queries).toEqual([
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
query: "effect typescript",
|
||||
url: WebSearchTool.EXA_URL,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps provider metadata in structured output", () =>
|
||||
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Result({
|
||||
providerID: Integration.ID.make("parallel"),
|
||||
text: "parallel results",
|
||||
metadata: { requestID: "req_1" },
|
||||
})
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("parallel results")
|
||||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
}),
|
||||
).toEqual({
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
})
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchTool.PARALLEL_URL,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
|
||||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "parallel results" },
|
||||
output: {
|
||||
structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } },
|
||||
structured: { provider: "parallel", text: "parallel results" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "" })
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("credentialed exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
|
||||
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
|
||||
expect(JSON.stringify(settled)).not.toContain("exa secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the legacy no-results fallback as concise model text", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = ""
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
@@ -136,4 +285,39 @@ describe("WebSearchTool registration", () => {
|
||||
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized MCP response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
let chunksRead = 0
|
||||
let cancelled = false
|
||||
makeResponse = () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
chunksRead++
|
||||
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
|
||||
controller.enqueue(new Uint8Array(64 * 1024))
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to search the web for too much" })
|
||||
expect(chunksRead).toBeLessThan(10)
|
||||
expect(cancelled).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import path from "node:path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigGlobal } from "@opencode-ai/core/config/global"
|
||||
import { ConfigWebSearch } from "@opencode-ai/core/config/websearch"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
let entries: Config.Entry[] = []
|
||||
const writes: { path: readonly (string | number)[]; value: unknown }[] = []
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
|
||||
const configGlobal = Layer.succeed(
|
||||
ConfigGlobal.Service,
|
||||
ConfigGlobal.Service.of({ update: (path, value) => Effect.sync(() => writes.push({ path, value })) }),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([WebSearch.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
|
||||
[
|
||||
[Config.node, config],
|
||||
[ConfigGlobal.node, configGlobal],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
const register = (id: string, connection: "optional" | "required" = "optional") =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make(id)
|
||||
const calls: { input: WebSearch.Input; credential?: Credential.Value; sessionID?: string }[] = []
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
|
||||
draft.websearch.update({
|
||||
integrationID,
|
||||
connection,
|
||||
execute: (input, context) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ input, ...context })
|
||||
return { text: `${id}: ${input.query}`, metadata: { id } }
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { integrationID, calls }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
entries = []
|
||||
writes.length = 0
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
it.effect("executes an explicit provider without changing the default", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect(yield* websearch.query({ query: "effect", providerID: provider.integrationID })).toEqual(
|
||||
new WebSearch.Result({
|
||||
providerID: provider.integrationID,
|
||||
text: "exa: effect",
|
||||
metadata: { id: "exa" },
|
||||
}),
|
||||
)
|
||||
expect(yield* websearch.selected()).toBeUndefined()
|
||||
expect(provider.calls).toEqual([
|
||||
{
|
||||
input: { query: "effect", providerID: provider.integrationID },
|
||||
credential: undefined,
|
||||
sessionID: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses and persists the global provider selection", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select(parallel.integrationID)
|
||||
|
||||
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
|
||||
expect(yield* websearch.selected()).toBe(parallel.integrationID)
|
||||
expect(writes).toEqual([
|
||||
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.integrationID }) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads the selected provider from global config", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(Global.Path.config, "opencode.json"),
|
||||
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.integrationID }) }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect(yield* websearch.selected()).toBe(provider.integrationID)
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.integrationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers the location config over the global selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select(exa.integrationID)
|
||||
entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.integrationID }) }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes concurrent first-use onboarding and persists the answer", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const forms = yield* Form.Service
|
||||
const first = yield* websearch.query({ query: "one", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
|
||||
const second = yield* websearch.query({ query: "two", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const pending = yield* forms.list({ sessionID: "ses_websearch" })
|
||||
expect(pending).toHaveLength(1)
|
||||
const form = pending[0]
|
||||
if (!form) return yield* Effect.die("Expected an onboarding form")
|
||||
yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } })
|
||||
|
||||
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
|
||||
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
|
||||
expect(yield* websearch.selected()).toBe(provider.integrationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a connection before invoking a required provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("private", "required")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect(
|
||||
yield* websearch.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(WebSearch.ConnectionRequiredError)
|
||||
expect(provider.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes scoped provider registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const provider = yield* register("temporary").pipe(Scope.provide(scope))
|
||||
expect(yield* integrations.websearch.get(provider.integrationID)).toBeDefined()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* integrations.websearch.get(provider.integrationID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -113,23 +113,6 @@ Keep provider facades small and explicit:
|
||||
|
||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode-ai/llm`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
transport: "websocket",
|
||||
})
|
||||
```
|
||||
|
||||
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`.
|
||||
|
||||
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
|
||||
|
||||
### Folder layout
|
||||
|
||||
```
|
||||
|
||||
@@ -106,32 +106,6 @@ const gateway = CloudflareAIGateway.configure({
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/llm` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
transport: "websocket",
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
```
|
||||
|
||||
OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
||||
|
||||
- `@opencode-ai/llm/providers/openai/chat`
|
||||
- `@opencode-ai/llm/providers/openai/responses`
|
||||
|
||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
|
||||
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
Three escape hatches in order of stability:
|
||||
|
||||
+16
-17
@@ -1,6 +1,6 @@
|
||||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-08
|
||||
Last reviewed: 2026-07-02
|
||||
|
||||
This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||
|
||||
@@ -64,27 +64,26 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh
|
||||
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
||||
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
|
||||
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
|
||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle.
|
||||
8. Package/namespace boundaries need to be made explicit in docs and exports. Protocol namespaces exist, but planned public groupings should call out OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat, OpenAI-compatible Responses, Anthropic Messages, Gemini, Vertex Gemini, Vertex Anthropic Messages, Bedrock Converse, and Bedrock Mantle as separate API slices.
|
||||
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
|
||||
|
||||
## Native Namespace Shape
|
||||
## Proposed Native Namespace Shape
|
||||
|
||||
These are implementation/API slices, not separate npm packages.
|
||||
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| --- | --- | --- |
|
||||
| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. |
|
||||
| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | Missing | Vertex Gemini API. |
|
||||
| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
| Namespace | Purpose |
|
||||
| --- | --- |
|
||||
| `OpenAI.Chat` or `OpenAIChat` | OpenAI `/chat/completions` semantics. |
|
||||
| `OpenAI.Responses` or `OpenAIResponses` | OpenAI `/responses` HTTP and WebSocket semantics. |
|
||||
| `OpenAICompatible.Chat` or `OpenAICompatibleChat` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| `OpenAICompatible.Responses` or `OpenAICompatibleResponses` | Generic OpenAI-compatible `/responses`. Missing today. |
|
||||
| `Anthropic.Messages` or `AnthropicMessages` | Anthropic Messages API. |
|
||||
| `Google.Gemini` or `Gemini` | Gemini Developer API. |
|
||||
| `GoogleVertex.Gemini` | Vertex Gemini API. Missing today. |
|
||||
| `GoogleVertex.AnthropicMessages` | Vertex-hosted Anthropic Messages API. Missing today. |
|
||||
| `Bedrock.Converse` or `BedrockConverse` | AWS Bedrock Converse API. |
|
||||
| `Bedrock.Mantle` | AWS Bedrock Mantle OpenAI-compatible APIs. Missing today. |
|
||||
| `Azure.OpenAIChat` / `Azure.OpenAIResponses` | Azure deployment specializations over OpenAI protocols. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
|
||||
@@ -342,24 +342,14 @@ const response =
|
||||
)
|
||||
```
|
||||
|
||||
For direct provider-facade calls, HTTP versus WebSocket is represented as named
|
||||
route selectors, not as model or request overrides. Same protocol, different
|
||||
transport, different route:
|
||||
HTTP versus WebSocket is represented as named route selectors, not as model or
|
||||
request overrides. Same protocol, different transport, different route:
|
||||
|
||||
```ts
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
```
|
||||
|
||||
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
|
||||
Responses settings while preserving the same `model(...)` contract:
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/openai/responses"
|
||||
|
||||
model("gpt-4o", { apiKey, transport: "websocket" })
|
||||
```
|
||||
|
||||
The client should not require a different public layer just because a selected
|
||||
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
||||
capabilities available; routes that do not need WebSocket simply never touch it.
|
||||
@@ -478,10 +468,10 @@ const model =
|
||||
```
|
||||
|
||||
That boundary can branch on durable config/catalog metadata and call typed
|
||||
provider APIs directly. A direct provider-facade boundary maps metadata like
|
||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
|
||||
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
|
||||
The client runtime only executes the route carried by the resulting model.
|
||||
provider APIs directly. Transport selection belongs there too: map metadata like
|
||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use
|
||||
the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes
|
||||
the route carried by the model.
|
||||
|
||||
## Competitive Shape
|
||||
|
||||
@@ -517,9 +507,8 @@ App boundary = explicit durable-config -> typed-provider call
|
||||
id.
|
||||
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
||||
endpoint/auth/deployment customization happens by configuring the route first.
|
||||
- No transport override on an executable model or request. Direct provider
|
||||
facades use `responses` versus `responsesWebSocket`; the package-like Responses
|
||||
entrypoint maps its scoped `transport` setting before constructing the model.
|
||||
- No transport override on model/request. HTTP SSE versus WebSocket is a named
|
||||
route selector such as `responses` versus `responsesWebSocket`.
|
||||
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
||||
client layer with the available transport capabilities.
|
||||
- No executable `ModelRef`. The executable handle is `Model`; durable model
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||
"./providers/anthropic": "./src/providers/anthropic.ts",
|
||||
"./providers/azure": "./src/providers/azure.ts",
|
||||
"./providers/azure/responses": "./src/providers/azure/responses.ts",
|
||||
"./providers/azure/chat": "./src/providers/azure/chat.ts",
|
||||
"./providers/cloudflare": "./src/providers/cloudflare.ts",
|
||||
"./providers/github-copilot": "./src/providers/github-copilot.ts",
|
||||
"./providers/google": "./src/providers/google.ts",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Auth } from "../route/auth"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
@@ -24,14 +23,6 @@ export type ModelOptions = AzureURL &
|
||||
}
|
||||
export type Config = ModelOptions
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AzureURL & {
|
||||
readonly apiKey?: string
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
@@ -117,24 +108,3 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const common = {
|
||||
apiKey: settings.apiKey,
|
||||
apiVersion: settings.apiVersion,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).chat(modelID)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { chatModel as model } from "../azure"
|
||||
export type { Settings } from "../azure"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { responsesModel as model } from "../azure"
|
||||
export type { Settings } from "../azure"
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import * as Gemini from "../protocols/gemini"
|
||||
|
||||
export const id = ProviderID.make("google")
|
||||
@@ -11,12 +10,6 @@ export const routes = [Gemini.route]
|
||||
|
||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
@@ -39,12 +32,4 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
export const model = provider.model
|
||||
|
||||
@@ -10,15 +10,10 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/llm/providers/anthropic"),
|
||||
import("@opencode-ai/llm/providers/openai-compatible"),
|
||||
import("@opencode-ai/llm/providers/amazon-bedrock"),
|
||||
import("@opencode-ai/llm/providers/azure"),
|
||||
import("@opencode-ai/llm/providers/azure/responses"),
|
||||
import("@opencode-ai/llm/providers/azure/chat"),
|
||||
import("@opencode-ai/llm/providers/google"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[6].model).toBe(modules[7].model)
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
@@ -54,49 +49,4 @@ describe("provider package entrypoints", () => {
|
||||
"OpenAI-Project": "proj_123",
|
||||
})
|
||||
})
|
||||
|
||||
test("selects Azure API entrypoints with the same model contract", async () => {
|
||||
const Azure = await import("@opencode-ai/llm/providers/azure")
|
||||
const AzureChat = await import("@opencode-ai/llm/providers/azure/chat")
|
||||
const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses")
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
resourceName: "opencode-test",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
|
||||
const responses = AzureResponses.model("deployment", settings)
|
||||
const chat = AzureChat.model("deployment", settings)
|
||||
|
||||
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
||||
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
})
|
||||
|
||||
test("maps Google package settings onto the Gemini model", async () => {
|
||||
const Google = await import("@opencode-ai/llm/providers/google")
|
||||
const selected = Google.model("gemini-2.5-flash", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,8 @@ import type {
|
||||
IntegrationRef,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { Registration, Transform } from "./registration.js"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
@@ -45,29 +44,6 @@ export type IntegrationMethodRegistration =
|
||||
readonly method: IntegrationEnvMethod
|
||||
}
|
||||
|
||||
export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
|
||||
readonly authorize: (inputs: IntegrationInputs) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
|
||||
readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined
|
||||
}
|
||||
|
||||
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
|
||||
|
||||
export interface IntegrationWebSearchDefinition {
|
||||
readonly connection: "optional" | "required"
|
||||
readonly execute: (
|
||||
input: WebSearch.Input,
|
||||
context: { readonly credential?: CredentialValue; readonly sessionID?: string },
|
||||
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods?: readonly IntegrationMethodDefinition[]
|
||||
readonly websearch?: IntegrationWebSearchDefinition
|
||||
}
|
||||
|
||||
export interface IntegrationDraft {
|
||||
list(): readonly IntegrationRef[]
|
||||
get(id: string): IntegrationRef | undefined
|
||||
@@ -81,7 +57,6 @@ export interface IntegrationDraft {
|
||||
}
|
||||
|
||||
export interface IntegrationDomain extends IntegrationApi<unknown> {
|
||||
readonly register: (definition: IntegrationDefinition) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
readonly transform: Transform<IntegrationDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
readonly connection: {
|
||||
|
||||
@@ -1,57 +1,11 @@
|
||||
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
||||
import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
CredentialValue,
|
||||
IntegrationEnvMethod,
|
||||
IntegrationInputs,
|
||||
IntegrationKeyMethod,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Registration, Transform } from "./registration.js"
|
||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Promise<CredentialOAuth>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Promise<CredentialOAuth>
|
||||
}
|
||||
)
|
||||
|
||||
export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
|
||||
readonly authorize: (inputs: IntegrationInputs) => Promise<IntegrationOAuthAuthorization>
|
||||
readonly refresh?: (credential: CredentialOAuth) => Promise<CredentialOAuth>
|
||||
readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined
|
||||
}
|
||||
|
||||
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
|
||||
|
||||
export interface IntegrationWebSearchDefinition {
|
||||
readonly connection: "optional" | "required"
|
||||
readonly execute: (
|
||||
input: WebSearch.Input,
|
||||
context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal },
|
||||
) => Promise<WebSearch.ProviderOutput>
|
||||
}
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods?: readonly IntegrationMethodDefinition[]
|
||||
readonly websearch?: IntegrationWebSearchDefinition
|
||||
}
|
||||
|
||||
export interface IntegrationDomain extends IntegrationApi {
|
||||
readonly register: (definition: IntegrationDefinition) => Promise<Registration>
|
||||
readonly transform: Transform<IntegrationDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
readonly connection: {
|
||||
|
||||
@@ -15,7 +15,7 @@ const PromisePlugin = await import("../src/v2/promise/index")
|
||||
test.each([
|
||||
["effect", Plugin],
|
||||
["promise", PromisePlugin],
|
||||
])("%s entrypoint exposes its canonical Schema contracts", (_name, entrypoint) => {
|
||||
])("%s entrypoint exposes its canonical Schema contracts", (name, entrypoint) => {
|
||||
expect(entrypoint.Agent).toBe(Agent)
|
||||
expect(entrypoint.Command).toBe(Command)
|
||||
expect(entrypoint.Connection).toBe(Connection)
|
||||
@@ -32,9 +32,10 @@ test.each([
|
||||
"Credential",
|
||||
"Integration",
|
||||
"Model",
|
||||
"Plugin",
|
||||
"Provider",
|
||||
"Reference",
|
||||
"Skill",
|
||||
...(name === "effect" ? ["Tool"] : []),
|
||||
"define",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -24,7 +24,6 @@ import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
import { LocationGroup } from "./groups/location.js"
|
||||
import { IntegrationGroup } from "./groups/integration.js"
|
||||
import { WebSearchGroup } from "./groups/websearch.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
@@ -39,7 +38,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof WebSearchGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
|
||||
@@ -170,7 +168,6 @@ const makeApiFromGroup = <
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(WebSearchGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const groupNames = {
|
||||
"server.generate": "generate",
|
||||
"server.provider": "provider",
|
||||
"server.integration": "integration",
|
||||
"server.websearch": "websearch",
|
||||
"server.credential": "credential",
|
||||
"server.form": "form",
|
||||
"server.permission": "permission",
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const WebSearchGroup = HttpApiGroup.make("server.websearch")
|
||||
.add(
|
||||
HttpApiEndpoint.get("websearch.provider.get", "/api/websearch/provider", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.UndefinedOr(Integration.ID)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.provider.get",
|
||||
summary: "Get default web search provider",
|
||||
description: "Return the globally selected web search provider.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("websearch.provider.select", "/api/websearch/provider", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({ providerID: Integration.ID }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.provider.select",
|
||||
summary: "Select default web search provider",
|
||||
description: "Persist the global web search provider in the user configuration.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("websearch.query", "/api/websearch", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct(WebSearch.Input.fields),
|
||||
success: Location.response(WebSearch.Result),
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.query",
|
||||
summary: "Search the web",
|
||||
description:
|
||||
"Run one web search through the selected integration. Specify a provider to override the configured default.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "websearch",
|
||||
description: "Location-scoped web search routes.",
|
||||
}),
|
||||
)
|
||||
@@ -3,7 +3,6 @@ export * as Form from "./form.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
||||
@@ -125,15 +124,8 @@ export const UrlInfo = Schema.Struct({
|
||||
}).annotate({ identifier: "Form.UrlInfo" })
|
||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||
|
||||
export const IntegrationInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("integration"),
|
||||
integrationID: IntegrationID,
|
||||
}).annotate({ identifier: "Form.IntegrationInfo" })
|
||||
export interface IntegrationInfo extends Schema.Schema.Type<typeof IntegrationInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo | IntegrationInfo
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo
|
||||
|
||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@ export { Project } from "./project.js"
|
||||
export { ProjectCopy } from "./project-copy.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { Reference } from "./reference.js"
|
||||
export { WebSearch } from "./websearch.js"
|
||||
export { Session } from "./session.js"
|
||||
export { Vcs } from "./vcs.js"
|
||||
export { SessionInput } from "./session-input.js"
|
||||
|
||||
@@ -76,11 +76,6 @@ export type Method = typeof Method.Type
|
||||
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
|
||||
export type Inputs = typeof Inputs.Type
|
||||
|
||||
export interface WebSearch extends Schema.Schema.Type<typeof WebSearch> {}
|
||||
export const WebSearch = Schema.Struct({
|
||||
connection: Schema.Literals(["optional", "required"]),
|
||||
}).annotate({ identifier: "Integration.WebSearch" })
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
@@ -101,7 +96,6 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
websearch: optional(WebSearch),
|
||||
connections: Schema.Array(Connection.Info),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -26,7 +26,24 @@ export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
input: Schema.Array(Schema.String),
|
||||
output: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "Model.Capabilities" })
|
||||
})
|
||||
.annotate({ identifier: "Model.Capabilities" })
|
||||
.pipe(
|
||||
statics((schema) => ({
|
||||
defaults: (
|
||||
input: {
|
||||
readonly tools?: boolean
|
||||
readonly input?: ReadonlyArray<string>
|
||||
readonly output?: ReadonlyArray<string>
|
||||
} = {},
|
||||
) =>
|
||||
schema.make({
|
||||
tools: input.tools ?? true,
|
||||
input: input.input === undefined ? ["text", "image"] : [...input.input],
|
||||
output: input.output === undefined ? ["text"] : [...input.output],
|
||||
}),
|
||||
})),
|
||||
)
|
||||
|
||||
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
|
||||
export const Cost = Schema.Struct({
|
||||
@@ -80,7 +97,7 @@ export const Info = Schema.Struct({
|
||||
modelID: id,
|
||||
providerID,
|
||||
name: id,
|
||||
capabilities: { tools: false, input: [], output: [] },
|
||||
capabilities: Capabilities.defaults(),
|
||||
variants: [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export interface Input extends Schema.Schema.Type<typeof Input> {}
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String,
|
||||
providerID: IntegrationID.pipe(optional),
|
||||
}).annotate({ identifier: "WebSearch.Input" })
|
||||
|
||||
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
|
||||
export const ProviderOutput = Schema.Struct({
|
||||
text: Schema.String,
|
||||
metadata: Schema.Json.pipe(optional),
|
||||
}).annotate({ identifier: "WebSearch.ProviderOutput" })
|
||||
|
||||
export class Result extends Schema.Class<Result>("WebSearch.Result")({
|
||||
providerID: IntegrationID,
|
||||
...ProviderOutput.fields,
|
||||
}) {}
|
||||
@@ -20,7 +20,6 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
const SDK = await import("../src/index")
|
||||
@@ -9,7 +8,6 @@ const SDK = await import("../src/index")
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.WebSearch).toBe(WebSearch)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
expect(Object.keys(SDK).sort()).toEqual([
|
||||
"AbsolutePath",
|
||||
@@ -38,6 +36,5 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"SessionMessage",
|
||||
"Skill",
|
||||
"Tool",
|
||||
"WebSearch",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -302,39 +302,6 @@ it.live(
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live("embedded client exposes integration-backed web search", () =>
|
||||
withEmbedded("opencode-embedded-websearch-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const providerID = fixture.sdk.Integration.ID.make("embedded-websearch")
|
||||
yield* opencode.plugin({
|
||||
id: `embedded-websearch-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
ctx.integration.register({
|
||||
id: providerID,
|
||||
name: "Embedded web search",
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const result = yield* opencode.websearch.query({
|
||||
query: "opencode",
|
||||
providerID,
|
||||
location: location(fixture),
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID,
|
||||
text: "Found opencode",
|
||||
metadata: { source: "embedded" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"Location-owned runner events reach the ready global client",
|
||||
() =>
|
||||
|
||||
@@ -462,12 +462,6 @@ import type {
|
||||
V2VcsDiffResponses,
|
||||
V2VcsStatusErrors,
|
||||
V2VcsStatusResponses,
|
||||
V2WebsearchProviderGetErrors,
|
||||
V2WebsearchProviderGetResponses,
|
||||
V2WebsearchProviderSelectErrors,
|
||||
V2WebsearchProviderSelectResponses,
|
||||
V2WebsearchQueryErrors,
|
||||
V2WebsearchQueryResponses,
|
||||
VcsApplyErrors,
|
||||
VcsApplyResponses,
|
||||
VcsDiffErrors,
|
||||
@@ -8259,123 +8253,6 @@ export class Debug extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Provider3 extends HeyApiClient {
|
||||
/**
|
||||
* Get default web search provider
|
||||
*
|
||||
* Return the globally selected web search provider.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<
|
||||
V2WebsearchProviderGetResponses,
|
||||
V2WebsearchProviderGetErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/websearch/provider",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select default web search provider
|
||||
*
|
||||
* Persist the global web search provider in the user configuration.
|
||||
*/
|
||||
public select<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
providerID?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "body", key: "providerID" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
V2WebsearchProviderSelectResponses,
|
||||
V2WebsearchProviderSelectErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/websearch/provider",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Websearch extends HeyApiClient {
|
||||
/**
|
||||
* Search the web
|
||||
*
|
||||
* Run one web search through the selected integration. Specify a provider to override the configured default.
|
||||
*/
|
||||
public query<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
query?: string
|
||||
providerID?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "body", key: "query" },
|
||||
{ in: "body", key: "providerID" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2WebsearchQueryResponses, V2WebsearchQueryErrors, ThrowOnError>({
|
||||
url: "/api/websearch",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _provider?: Provider3
|
||||
get provider(): Provider3 {
|
||||
return (this._provider ??= new Provider3({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class V2 extends HeyApiClient {
|
||||
private _health?: Health
|
||||
get health(): Health {
|
||||
@@ -8506,11 +8383,6 @@ export class V2 extends HeyApiClient {
|
||||
get debug(): Debug {
|
||||
return (this._debug ??= new Debug({ client: this.client }))
|
||||
}
|
||||
|
||||
private _websearch?: Websearch
|
||||
get websearch(): Websearch {
|
||||
return (this._websearch ??= new Websearch({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class OpencodeClient extends HeyApiClient {
|
||||
|
||||
@@ -1431,7 +1431,7 @@ export type GlobalEvent = {
|
||||
id: string
|
||||
type: "form.created"
|
||||
properties: {
|
||||
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -3571,15 +3571,6 @@ export type FormUrlInfo = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FormIntegrationInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title?: string
|
||||
metadata?: FormMetadata
|
||||
mode: "integration"
|
||||
integrationID: string
|
||||
}
|
||||
|
||||
export type FormValue =
|
||||
| string
|
||||
| number
|
||||
@@ -5721,10 +5712,6 @@ export type IntegrationEnvMethod = {
|
||||
names: Array<string>
|
||||
}
|
||||
|
||||
export type IntegrationWebSearch = {
|
||||
connection: "optional" | "required"
|
||||
}
|
||||
|
||||
export type ConnectionCredentialInfo = {
|
||||
type: "credential"
|
||||
id: string
|
||||
@@ -5742,7 +5729,6 @@ export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
methods: Array<IntegrationMethod>
|
||||
websearch?: IntegrationWebSearch
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
@@ -6571,7 +6557,7 @@ export type FormCreated = {
|
||||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7035,12 +7021,6 @@ export type ProjectCopyCopy = {
|
||||
|
||||
export type VcsMode = "working" | "branch"
|
||||
|
||||
export type WebSearchResult = {
|
||||
providerID: string
|
||||
text: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type EventModelsDevRefreshed = {
|
||||
id: string
|
||||
type: "models-dev.refreshed"
|
||||
@@ -7864,7 +7844,7 @@ export type EventFormCreated = {
|
||||
id: string
|
||||
type: "form.created"
|
||||
properties: {
|
||||
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9913,15 +9893,6 @@ export type FormUrlInfoV2 = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FormIntegrationInfoV2 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title?: string
|
||||
metadata?: FormMetadata
|
||||
mode: "integration"
|
||||
integrationID: string
|
||||
}
|
||||
|
||||
export type FormCreatePayloadV2 = {
|
||||
id?: string | null
|
||||
title?: string
|
||||
@@ -10657,15 +10628,6 @@ export type FormUrlInfo1 = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FormIntegrationInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title?: string
|
||||
metadata?: FormMetadata1
|
||||
mode: "integration"
|
||||
integrationID: string
|
||||
}
|
||||
|
||||
export type FormCreatedV2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -10675,7 +10637,7 @@ export type FormCreatedV2 = {
|
||||
type: "form.created"
|
||||
location?: LocationRefV2
|
||||
data: {
|
||||
form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1
|
||||
form: FormFormInfo1 | FormUrlInfo1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17232,7 +17194,7 @@ export type V2FormRequestListResponses = {
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17269,7 +17231,7 @@ export type V2SessionFormListResponses = {
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17310,7 +17272,7 @@ export type V2SessionFormCreateResponses = {
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
|
||||
data: FormFormInfoV2 | FormUrlInfoV2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17348,7 +17310,7 @@ export type V2SessionFormGetResponses = {
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
|
||||
data: FormFormInfoV2 | FormUrlInfoV2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18955,128 +18917,6 @@ export type V2DebugLocationListResponses = {
|
||||
|
||||
export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses]
|
||||
|
||||
export type V2WebsearchProviderGetData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/websearch/provider"
|
||||
}
|
||||
|
||||
export type V2WebsearchProviderGetErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2WebsearchProviderGetError = V2WebsearchProviderGetErrors[keyof V2WebsearchProviderGetErrors]
|
||||
|
||||
export type V2WebsearchProviderGetResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type V2WebsearchProviderGetResponse = V2WebsearchProviderGetResponses[keyof V2WebsearchProviderGetResponses]
|
||||
|
||||
export type V2WebsearchProviderSelectData = {
|
||||
body: {
|
||||
providerID: string
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/websearch/provider"
|
||||
}
|
||||
|
||||
export type V2WebsearchProviderSelectErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* ServiceUnavailableError
|
||||
*/
|
||||
503: ServiceUnavailableErrorV2
|
||||
}
|
||||
|
||||
export type V2WebsearchProviderSelectError = V2WebsearchProviderSelectErrors[keyof V2WebsearchProviderSelectErrors]
|
||||
|
||||
export type V2WebsearchProviderSelectResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2WebsearchProviderSelectResponse =
|
||||
V2WebsearchProviderSelectResponses[keyof V2WebsearchProviderSelectResponses]
|
||||
|
||||
export type V2WebsearchQueryData = {
|
||||
body: {
|
||||
query: string
|
||||
providerID?: string
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/websearch"
|
||||
}
|
||||
|
||||
export type V2WebsearchQueryErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* ServiceUnavailableError
|
||||
*/
|
||||
503: ServiceUnavailableErrorV2
|
||||
}
|
||||
|
||||
export type V2WebsearchQueryError = V2WebsearchQueryErrors[keyof V2WebsearchQueryErrors]
|
||||
|
||||
export type V2WebsearchQueryResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: WebSearchResult
|
||||
}
|
||||
}
|
||||
|
||||
export type V2WebsearchQueryResponse = V2WebsearchQueryResponses[keyof V2WebsearchQueryResponses]
|
||||
|
||||
export type PtyConnectData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { QuestionHandler } from "./handlers/question"
|
||||
import { ReferenceHandler } from "./handlers/reference"
|
||||
import { LocationHandler } from "./handlers/location"
|
||||
import { IntegrationHandler } from "./handlers/integration"
|
||||
import { WebSearchHandler } from "./handlers/websearch"
|
||||
import { McpHandler } from "./handlers/mcp"
|
||||
import { CredentialHandler } from "./handlers/credential"
|
||||
import { ProjectHandler } from "./handlers/project"
|
||||
@@ -39,7 +38,6 @@ export const handlers = Layer.mergeAll(
|
||||
GenerateHandler,
|
||||
ProviderHandler,
|
||||
IntegrationHandler,
|
||||
WebSearchHandler,
|
||||
McpHandler,
|
||||
CredentialHandler,
|
||||
ProjectHandler,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const awaitPlugins = Effect.fn("server.websearch.awaitPlugins")(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.ready.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Web search integration initialization timed out",
|
||||
service: "websearch",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
return handlers
|
||||
.handle(
|
||||
"websearch.provider.get",
|
||||
Effect.fn("server.websearch.provider.get")(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(websearch.selected())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"websearch.provider.select",
|
||||
Effect.fn("server.websearch.provider.select")(function* (request) {
|
||||
yield* awaitPlugins()
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select(request.payload.providerID).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Web search provider not found: ${error.providerID}`,
|
||||
kind: "websearch_provider_not_found",
|
||||
field: "providerID",
|
||||
}),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"websearch.query",
|
||||
Effect.fn("server.websearch.query")(function* (request) {
|
||||
yield* awaitPlugins()
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(
|
||||
websearch.query(request.payload).pipe(
|
||||
Effect.catchTags({
|
||||
"WebSearch.ProviderRequired": () =>
|
||||
new InvalidRequestError({
|
||||
message: "Web search provider is required",
|
||||
kind: "websearch_provider_required",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.ProviderNotFound": (error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Web search provider not found: ${error.providerID}`,
|
||||
kind: "websearch_provider_not_found",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.ConnectionRequired": (error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Web search provider requires a connection: ${error.providerID}`,
|
||||
kind: "websearch_connection_required",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.Cancelled": () =>
|
||||
new InvalidRequestError({ message: "Web search cancelled", kind: "websearch_cancelled" }),
|
||||
"WebSearch.Request": (error) =>
|
||||
new ServiceUnavailableError({
|
||||
message: `Web search request failed: ${error.providerID}`,
|
||||
service: error.providerID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -18,13 +18,7 @@ import { Effect, Queue } from "effect"
|
||||
export type Item =
|
||||
| { readonly type: "textDelta"; readonly text: string }
|
||||
| { readonly type: "reasoningDelta"; readonly text: string }
|
||||
| {
|
||||
readonly type: "toolCall"
|
||||
readonly index: number
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
}
|
||||
| { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown }
|
||||
| { readonly type: "raw"; readonly chunk: unknown }
|
||||
|
||||
export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter"
|
||||
|
||||
@@ -30,11 +30,7 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown {
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: item.index,
|
||||
id: item.id,
|
||||
function: { name: item.name, arguments: JSON.stringify(item.input) },
|
||||
},
|
||||
{ index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -141,13 +141,7 @@ export namespace Backend {
|
||||
export const Item = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("toolCall"),
|
||||
index: Schema.Number,
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
input: Schema.Json,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }),
|
||||
Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }),
|
||||
])
|
||||
export type Item = Schema.Schema.Type<typeof Item>
|
||||
|
||||
@@ -53,47 +53,28 @@ export function connectionSummary(integration: IntegrationInfo) {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
export function DialogIntegration(
|
||||
props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
|
||||
) {
|
||||
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const options = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list() ?? [])
|
||||
.filter((integration) => props.integrationID === undefined || integration.id === props.integrationID)
|
||||
.map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const connected = integration.connections.length > 0
|
||||
const websearch = integration.websearch
|
||||
const selected = data.location.websearch.provider() === integration.id
|
||||
const credentials = credentialConnections(integration)
|
||||
const description = websearch?.connection === "optional" ? "API key optional" : undefined
|
||||
const category = websearch ? "Web search" : undefined
|
||||
return {
|
||||
title: integration.name,
|
||||
value: integration.id,
|
||||
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
|
||||
footer:
|
||||
[connectionSummary(integration), selected ? "Web search default" : undefined]
|
||||
.filter((value) => value !== undefined && value.length > 0)
|
||||
.join(" · ") || undefined,
|
||||
category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"),
|
||||
disabled: methods.length === 0 && !websearch,
|
||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
onSelect: () => {
|
||||
if (props.connectionOnly) {
|
||||
return credentials.length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||
}
|
||||
if (websearch) return manageIntegration(integration, methods, websearch, dialog)
|
||||
return credentials.length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||
},
|
||||
}
|
||||
}),
|
||||
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const connected = integration.connections.length > 0
|
||||
return {
|
||||
title: integration.name,
|
||||
value: integration.id,
|
||||
description: methods.length ? undefined : "Environment only",
|
||||
footer: connectionSummary(integration) || undefined,
|
||||
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
|
||||
disabled: methods.length === 0,
|
||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
onSelect: () =>
|
||||
credentialConnections(integration).length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
: selectMethod(integration, methods, dialog, props.onConnected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -109,64 +90,6 @@ export function DialogIntegration(
|
||||
)
|
||||
}
|
||||
|
||||
function manageIntegration(
|
||||
integration: IntegrationInfo,
|
||||
methods: ConnectMethod[],
|
||||
websearch: NonNullable<IntegrationInfo["websearch"]>,
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
) {
|
||||
const connected = integration.connections.length > 0
|
||||
dialog.replace(() => {
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const credentials = credentialConnections(integration)
|
||||
const selected = createMemo(() => data.location.websearch.provider() === integration.id)
|
||||
const selectWebSearch = () => {
|
||||
void sdk.api.websearch.provider
|
||||
.select({
|
||||
providerID: integration.id,
|
||||
location: location(data),
|
||||
})
|
||||
.then(async () => {
|
||||
await Promise.all([data.location.integration.refresh(), data.location.websearch.refresh()])
|
||||
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
|
||||
dialog.clear()
|
||||
})
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={integration.name}
|
||||
options={[
|
||||
{
|
||||
title: selected() ? "Web search default" : "Use for web search",
|
||||
value: "websearch",
|
||||
disabled: selected(),
|
||||
onSelect:
|
||||
websearch.connection === "required" && !connected
|
||||
? () => selectMethod(integration, methods, dialog, selectWebSearch)
|
||||
: selectWebSearch,
|
||||
},
|
||||
...(methods.length
|
||||
? [
|
||||
{
|
||||
title: credentials.length ? "Manage connections" : "Connect",
|
||||
value: "connect",
|
||||
onSelect: () =>
|
||||
credentials.length
|
||||
? manageConnections(integration, methods, dialog)
|
||||
: selectMethod(integration, methods, dialog),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function manageConnections(
|
||||
integration: IntegrationInfo,
|
||||
methods: ConnectMethod[],
|
||||
@@ -256,8 +179,8 @@ function KeyMethod(props: {
|
||||
placeholder="API key"
|
||||
onConfirm={(key) => {
|
||||
if (!key) return
|
||||
void sdk.api.integration.connect
|
||||
.key({
|
||||
void sdk.api.integration
|
||||
.connect.key({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
key,
|
||||
@@ -295,8 +218,8 @@ function OAuthStarting(props: {
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
void sdk.api.integration.connect
|
||||
.oauth({
|
||||
void sdk.api.integration
|
||||
.connect.oauth({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
methodID: props.method.id,
|
||||
@@ -364,8 +287,8 @@ function OAuthAuto(props: {
|
||||
}))
|
||||
|
||||
const poll = () => {
|
||||
void sdk.api.integration.attempt
|
||||
.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void sdk.api.integration
|
||||
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
.then((result) => {
|
||||
const status = result.data
|
||||
if (status.status === "pending") {
|
||||
@@ -430,8 +353,8 @@ function OAuthCode(props: {
|
||||
placeholder="Authorization code"
|
||||
onConfirm={(code) => {
|
||||
if (!code) return
|
||||
void sdk.api.integration.attempt
|
||||
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
void sdk.api.integration
|
||||
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
.then(() => {
|
||||
settled = true
|
||||
return connected(props.integration, data, dialog, toast, props.onConnected)
|
||||
|
||||
+120
-147
@@ -1,13 +1,7 @@
|
||||
// Client data layer: apply server events and cache API reads into a Solid store.
|
||||
// Prefer straightforward projection. Do not add generation counters, stale-response
|
||||
// merges, live/history overlays, or other race machinery here—last write wins.
|
||||
// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns.
|
||||
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormFormInfo,
|
||||
FormIntegrationInfo,
|
||||
FormUrlInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
@@ -30,23 +24,13 @@ import type {
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { batch, createSignal, onCleanup } from "solid-js"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
const MESSAGE_PAGE_SIZE = 25
|
||||
|
||||
export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
|
||||
// Per-session message timeline plus older-history paging. `items` is ascending;
|
||||
// `cursor` is the opaque server cursor for the next older page after a desc first load.
|
||||
type SessionMessages = {
|
||||
items: SessionMessageInfo[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
loading: boolean
|
||||
}
|
||||
export type FormInfo = FormFormInfo | FormUrlInfo
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
@@ -56,7 +40,6 @@ type LocationData = {
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderV2Info[]
|
||||
reference?: ReferenceInfo[]
|
||||
websearchProvider?: string | 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, Shell>
|
||||
@@ -71,9 +54,7 @@ type Data = {
|
||||
// session ID in that family, including the key itself once its info arrives.
|
||||
family: Record<string, string[]>
|
||||
status: Record<string, DataSessionStatus>
|
||||
compaction: Partial<Record<string, string>>
|
||||
compactionReason: Partial<Record<string, "auto" | "manual">>
|
||||
message: Record<string, SessionMessages>
|
||||
message: Record<string, SessionMessageInfo[]>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
// Pending forms keyed by session ID.
|
||||
@@ -85,10 +66,6 @@ type Data = {
|
||||
location: Record<string, LocationData>
|
||||
}
|
||||
|
||||
function emptyMessages(): SessionMessages {
|
||||
return { items: [], complete: false, loading: false }
|
||||
}
|
||||
|
||||
function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
@@ -113,8 +90,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
info: {},
|
||||
family: {},
|
||||
status: {},
|
||||
compaction: {},
|
||||
compactionReason: {},
|
||||
message: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
@@ -131,19 +106,43 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const sessionRefreshGeneration = new Map<string, number>()
|
||||
const sessionRefreshApplied = new Map<string, number>()
|
||||
const sessionUsage = new Map<string, { generation: number; cost: number; tokens: SessionInfo["tokens"] }>()
|
||||
let connectionGeneration = 0
|
||||
let statusChanges: Set<string> | undefined
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
|
||||
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
||||
statusChanges?.add(sessionID)
|
||||
setStore("session", "status", sessionID, status)
|
||||
}
|
||||
|
||||
function nextSessionRefresh(sessionID: string) {
|
||||
const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1
|
||||
sessionRefreshGeneration.set(sessionID, generation)
|
||||
return generation
|
||||
}
|
||||
|
||||
function applySessionRefresh(sessionID: string, generation: number) {
|
||||
if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false
|
||||
sessionRefreshApplied.set(sessionID, generation)
|
||||
return true
|
||||
}
|
||||
|
||||
function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) {
|
||||
sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens })
|
||||
if (!store.session.info[sessionID]) return
|
||||
setStore("session", "info", sessionID, { cost, tokens })
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
"session",
|
||||
"message",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID))
|
||||
fn((draft[sessionID] ??= []), index(sessionID))
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -238,13 +237,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID))
|
||||
sessionUsage.delete(sessionID)
|
||||
messageIndex.delete(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
delete draft.info[sessionID]
|
||||
delete draft.status[sessionID]
|
||||
delete draft.compaction[sessionID]
|
||||
delete draft.message[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
@@ -267,11 +267,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
removeSession(event.data.sessionID)
|
||||
break
|
||||
case "session.usage.updated":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, {
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
})
|
||||
updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens)
|
||||
break
|
||||
case "catalog.updated":
|
||||
void Promise.all([
|
||||
@@ -378,6 +374,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
@@ -389,6 +386,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
@@ -402,6 +400,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
@@ -410,7 +409,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.shell(draft, event.data.shell.id)
|
||||
if (!match) return
|
||||
match.command = event.data.shell.command
|
||||
match.status = event.data.shell.status
|
||||
match.exit = event.data.shell.exit
|
||||
match.output = event.data.output
|
||||
@@ -441,6 +439,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.metadata,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
@@ -629,15 +628,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "session.compaction.admitted":
|
||||
break
|
||||
case "session.compaction.started":
|
||||
setStore("session", "compaction", event.data.sessionID, "")
|
||||
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) {
|
||||
current.status = "running"
|
||||
current.reason = event.data.reason
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
@@ -653,10 +644,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
setSessionStatus(event.data.sessionID, "idle")
|
||||
if (store.session.compaction[event.data.sessionID] !== undefined)
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
if (store.session.compactionReason[event.data.sessionID] !== undefined)
|
||||
setStore("session", "compactionReason", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
@@ -687,27 +674,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.compaction.delta":
|
||||
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) current.summary += event.data.text
|
||||
if (current?.status === "running") current.summary += event.data.text
|
||||
})
|
||||
break
|
||||
case "session.compaction.ended":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
setStore("session", "compactionReason", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) {
|
||||
const position = index.get(current.id)
|
||||
if (position !== undefined)
|
||||
draft[position] = {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
}
|
||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||
const current = draft[position]
|
||||
if (current?.type === "compaction") {
|
||||
draft[position] = {
|
||||
id: current.id,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
metadata: current.metadata,
|
||||
time: current.time,
|
||||
}
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
@@ -722,19 +708,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.compaction.failed":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
setStore("session", "compactionReason", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const current = message.compaction(draft)
|
||||
if (!current) return
|
||||
const position = index.get(current.id)
|
||||
if (position !== undefined)
|
||||
draft[position] = {
|
||||
...current,
|
||||
status: "failed",
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||
const current = draft[position]
|
||||
const failed: Extract<SessionMessageInfo, { type: "compaction"; status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: event.data.reason ?? "manual",
|
||||
error: event.data.error ?? {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
metadata: current?.type === "compaction" ? current.metadata : event.metadata,
|
||||
time: current?.type === "compaction" ? current.time : { created: event.created },
|
||||
}
|
||||
if (current?.type === "compaction") {
|
||||
draft[position] = failed
|
||||
return
|
||||
}
|
||||
message.append(draft, index, failed)
|
||||
})
|
||||
break
|
||||
case "permission.v2.asked":
|
||||
@@ -802,9 +795,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
result.location.provider.refresh(event.location),
|
||||
])
|
||||
break
|
||||
case "config.updated":
|
||||
void result.location.websearch.refresh(event.location)
|
||||
break
|
||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||
// so the mcp list refreshes here rather than off integration.updated.
|
||||
case "mcp.status.changed":
|
||||
@@ -855,73 +845,50 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.input[sessionID]?.includes(inputID) ?? false
|
||||
},
|
||||
},
|
||||
compaction(sessionID: string) {
|
||||
return store.session.compaction[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||
const generation = nextSessionRefresh(sessionID)
|
||||
const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0
|
||||
const info = mutable(await sdk.api.session.get({ sessionID }))
|
||||
if (!applySessionRefresh(sessionID, generation)) return
|
||||
const usage = sessionUsage.get(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
sessionID,
|
||||
usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info,
|
||||
)
|
||||
registerSession(sessionID)
|
||||
},
|
||||
message: {
|
||||
ids(sessionID: string) {
|
||||
return (store.session.message[sessionID]?.items ?? []).map((message) => message.id)
|
||||
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.message[sessionID]?.items ?? []
|
||||
return store.session.message[sessionID] ?? []
|
||||
},
|
||||
get(sessionID: string, messageID: string) {
|
||||
const messages = store.session.message[sessionID]?.items
|
||||
const messages = store.session.message[sessionID]
|
||||
const position = messageIndex.get(sessionID)?.get(messageID)
|
||||
return position === undefined ? undefined : messages?.[position]
|
||||
},
|
||||
cursor(sessionID: string) {
|
||||
return store.session.message[sessionID]?.cursor
|
||||
},
|
||||
complete(sessionID: string) {
|
||||
return store.session.message[sessionID]?.complete ?? false
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
return store.session.message[sessionID]?.loading ?? false
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "message", sessionID, { ...emptyMessages(), loading: true })
|
||||
const live = [...(store.session.message[sessionID] ?? [])]
|
||||
setStore("session", "message", sessionID, [])
|
||||
messageIndex.set(sessionID, new Map())
|
||||
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" })
|
||||
const items = mutable(response.data).toReversed()
|
||||
messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, {
|
||||
items,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length < MESSAGE_PAGE_SIZE,
|
||||
loading: false,
|
||||
})
|
||||
const running = items.find((message) => message.type === "compaction" && message.status === "running")
|
||||
setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined)
|
||||
setStore(
|
||||
"session",
|
||||
"compactionReason",
|
||||
sessionID,
|
||||
running?.type === "compaction" ? running.reason : undefined,
|
||||
)
|
||||
},
|
||||
async more(sessionID: string) {
|
||||
const current = store.session.message[sessionID]
|
||||
if (!current || current.loading || current.complete || !current.cursor) return
|
||||
const cursor = current.cursor
|
||||
setStore("session", "message", sessionID, "loading", true)
|
||||
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor })
|
||||
const older = mutable(response.data).toReversed()
|
||||
const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id))
|
||||
const items = [...prepend, ...current.items]
|
||||
messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position])))
|
||||
batch(() => {
|
||||
setStore("session", "message", sessionID, "items", items)
|
||||
setStore("session", "message", sessionID, {
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length < MESSAGE_PAGE_SIZE,
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
const loaded = mutable(
|
||||
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
|
||||
).toReversed()
|
||||
const loadedIDs = new Set(loaded.map((message) => message.id))
|
||||
const liveByID = new Map(live.map((message) => [message.id, message]))
|
||||
const messages = [
|
||||
...loaded.map((message) => {
|
||||
if (message.type === "user") return message
|
||||
return liveByID.get(message.id) ?? message
|
||||
}),
|
||||
...live.filter((message) => !loadedIDs.has(message.id)),
|
||||
].toSorted((a, b) => a.time.created - b.time.created)
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, messages)
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
@@ -1049,16 +1016,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setStore("location", key, { ...store.location[key], reference: mutable(result.data) })
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
provider(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.websearchProvider ?? undefined
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.websearch.provider.get({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], websearchProvider: result.data ?? null })
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
@@ -1074,6 +1031,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapping) return bootstrapping
|
||||
const generation = new Map(sessionRefreshApplied)
|
||||
const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation]))
|
||||
bootstrapping = Promise.allSettled([
|
||||
sdk.api.session
|
||||
.list({
|
||||
@@ -1087,7 +1046,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of response.data) draft[session.id] = mutable(session)
|
||||
for (const session of response.data) {
|
||||
if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue
|
||||
const usage = sessionUsage.get(session.id)
|
||||
draft[session.id] = mutable(
|
||||
usage && usage.generation !== (usageGeneration.get(session.id) ?? 0)
|
||||
? { ...session, cost: usage.cost, tokens: usage.tokens }
|
||||
: session,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const session of response.data) registerSession(session.id)
|
||||
@@ -1119,7 +1086,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
result.location.model.refresh(),
|
||||
result.location.provider.refresh(),
|
||||
result.location.reference.refresh(),
|
||||
result.location.websearch.refresh(),
|
||||
result.location.command.refresh(),
|
||||
result.location.skill.refresh(),
|
||||
result.shell.refresh(),
|
||||
@@ -1135,16 +1101,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
const generation = ++connectionGeneration
|
||||
const changed = new Set<string>()
|
||||
statusChanges = changed
|
||||
void sdk.api.session
|
||||
.active()
|
||||
.then((active) => {
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||
if (generation !== connectionGeneration) return
|
||||
const status: Record<string, DataSessionStatus> = Object.fromEntries(
|
||||
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
|
||||
)
|
||||
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
|
||||
setStore("session", "status", reconcile(status))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (statusChanges === changed) statusChanges = undefined
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
|
||||
@@ -10,8 +10,6 @@ import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
import { DialogIntegration } from "../../component/dialog-integration"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
|
||||
@@ -133,50 +131,7 @@ function display(field: Field, value: FormValue | undefined) {
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormInfo }) {
|
||||
if (props.form.mode === "url") return <UrlPrompt form={props.form} />
|
||||
if (props.form.mode === "integration") return <IntegrationPrompt form={props.form} />
|
||||
return <FieldsPrompt form={props.form} />
|
||||
}
|
||||
|
||||
function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
let settled = false
|
||||
|
||||
onMount(() => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogIntegration
|
||||
integrationID={props.form.integrationID}
|
||||
connectionOnly
|
||||
onConnected={() => {
|
||||
settled = true
|
||||
dialog.clear()
|
||||
void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} })
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (settled) return
|
||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<text fg={theme.text}>{props.form.title ?? "Connect integration"}</text>
|
||||
<text fg={theme.textMuted}>Complete the connection dialog to continue.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
||||
}
|
||||
|
||||
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||
|
||||
@@ -198,16 +198,6 @@ export function Session() {
|
||||
?.id
|
||||
})
|
||||
|
||||
// Admitted inputs and in-flight manual compaction sit after history, not in rows.
|
||||
const pendingMessages = createMemo(() => {
|
||||
const boundary = session()?.revert?.messageID
|
||||
return messages().filter((message) => {
|
||||
if (boundary && message.id >= boundary) return false
|
||||
if (data.session.input.has(route.sessionID, message.id)) return true
|
||||
return message.type === "compaction" && message.status === "running"
|
||||
})
|
||||
})
|
||||
|
||||
const lastAssistant = createMemo(() => {
|
||||
return messages().findLast((x) => x.type === "assistant")
|
||||
})
|
||||
@@ -251,44 +241,37 @@ export function Session() {
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => route.sessionID,
|
||||
(sessionID) => {
|
||||
void (async () => {
|
||||
if (data.session.message.list(sessionID).length === 0) {
|
||||
await Promise.all([
|
||||
data.session.refresh(sessionID),
|
||||
data.session.message.refresh(sessionID),
|
||||
data.session.permission.refresh(sessionID),
|
||||
data.session.form.refresh(sessionID),
|
||||
])
|
||||
}
|
||||
const info = data.session.get(sessionID)
|
||||
if (!info) {
|
||||
toast.show({
|
||||
message: `Session not found: ${sessionID}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
project.workspace.set(info.location.workspaceID)
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
toast.show({
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
createEffect(() => {
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
data.session.refresh(sessionID),
|
||||
data.session.permission.refresh(sessionID),
|
||||
data.session.form.refresh(sessionID),
|
||||
])
|
||||
const info = data.session.get(sessionID)
|
||||
if (!info) {
|
||||
toast.show({
|
||||
message: `Session not found: ${sessionID}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
project.workspace.set(info.location.workspaceID)
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
toast.show({
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
})
|
||||
})
|
||||
|
||||
let seeded = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
@@ -344,14 +327,12 @@ export function Session() {
|
||||
|
||||
if (!targetID) {
|
||||
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
|
||||
if (direction === "prev") loadOlder()
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const child = scroll.getChildren().find((c) => c.id === targetID)
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
if (direction === "prev") loadOlder()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
@@ -362,24 +343,6 @@ export function Session() {
|
||||
}, 50)
|
||||
}
|
||||
|
||||
let loadingOlder = false
|
||||
function loadOlder() {
|
||||
if (loadingOlder || scroll.scrollTop > 2) return
|
||||
loadingOlder = true
|
||||
const before = scroll.scrollHeight
|
||||
void data.session.message.more(route.sessionID).then(
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before)
|
||||
loadingOlder = false
|
||||
}, 50)
|
||||
},
|
||||
() => {
|
||||
loadingOlder = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const sessionCommandList = createMemo(() => [
|
||||
{
|
||||
title: "Share session",
|
||||
@@ -585,7 +548,6 @@ export function Session() {
|
||||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-scroll.height / 2)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -606,7 +568,6 @@ export function Session() {
|
||||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-1)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -627,7 +588,6 @@ export function Session() {
|
||||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-scroll.height / 4)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -648,7 +608,6 @@ export function Session() {
|
||||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollTo(0)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -958,9 +917,6 @@ export function Session() {
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
onMouseScroll={(event) => {
|
||||
if (event.scroll?.direction === "up") void loadOlder()
|
||||
}}
|
||||
>
|
||||
<For each={rows}>
|
||||
{(row) => (
|
||||
@@ -970,13 +926,6 @@ export function Session() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<For each={pendingMessages()}>
|
||||
{(message) => (
|
||||
<box marginTop={1} flexShrink={0}>
|
||||
<SessionMessageView message={message} />
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
@@ -2381,18 +2330,9 @@ function WebFetch(props: ToolProps) {
|
||||
}
|
||||
|
||||
function WebSearch(props: ToolProps) {
|
||||
const data = useData()
|
||||
const [provider, setProvider] = createSignal(
|
||||
data.location.websearch.provider() ?? stringValue(props.metadata.provider),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (provider()) return
|
||||
const next = data.location.websearch.provider()
|
||||
if (next) setProvider(next)
|
||||
})
|
||||
return (
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
|
||||
{webSearchProviderLabel(provider())} "{stringValue(props.input.query)}"{" "}
|
||||
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
|
||||
<Show when={numberValue(props.metadata.numResults)}>({numberValue(props.metadata.numResults)} results)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export type PartRef = {
|
||||
@@ -25,21 +25,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
|
||||
function pendingIDs() {
|
||||
const inputs = data.session.input.list(sessionID())
|
||||
const pending = new Set(inputs)
|
||||
for (const message of data.session.message.list(sessionID())) {
|
||||
if (message.type === "compaction" && message.status === "running") pending.add(message.id)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const boundary = revertBoundary()
|
||||
const visible = boundary ? messages.filter((message) => message.id < boundary) : messages
|
||||
const pending = pendingIDs()
|
||||
const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id)))
|
||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
|
||||
partitionPending(rows, pendingPermissions())
|
||||
return rows
|
||||
}
|
||||
@@ -62,30 +52,48 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(sessionID, () => {
|
||||
setRows(reduce())
|
||||
on(sessionID, (id) => {
|
||||
setRows(reconcile(reduce()))
|
||||
void data.session.message.refresh(id).then(
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
// Re-reduce when the revert boundary changes (stage/clear/commit).
|
||||
createEffect(
|
||||
on(revertBoundary, () => {
|
||||
setRows(reduce())
|
||||
setRows(reconcile(reduce()))
|
||||
}),
|
||||
)
|
||||
|
||||
// Pending inputs and compaction leaving the pending set change history membership.
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const pending = data.session.input.list(sessionID()).join("\0")
|
||||
const compaction = messages
|
||||
.filter((message) => message.type === "compaction")
|
||||
.map((message) => `${message.id}:${message.status}`)
|
||||
.join("\0")
|
||||
return `${pending}\u0001${compaction}`
|
||||
},
|
||||
() => setRows(reduce()),
|
||||
() =>
|
||||
data.session.message.list(sessionID()).flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: data.session.input.has(sessionID(), message.id),
|
||||
},
|
||||
]
|
||||
: message.type === "compaction"
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: message.status === "running",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
() => setRows(reconcile(reduce())),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -93,9 +101,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
|
||||
if (pendingIDs().has(messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "message", messageID })
|
||||
const pending = isPending(messageID)
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
const index =
|
||||
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
|
||||
if (!pending) completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "message", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -103,14 +114,15 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (hasPart(draft, ref)) return
|
||||
const index = queuedStart(draft)
|
||||
if (name && exploration(name)) {
|
||||
const previous = draft.at(-1)
|
||||
const previous = draft[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(draft)
|
||||
draft.push({
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, {
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
refs: [ref],
|
||||
@@ -119,8 +131,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
})
|
||||
return
|
||||
}
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "part", ref })
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "part", ref })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -128,8 +140,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "assistant-footer", messageID })
|
||||
const index = queuedStart(draft)
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -141,13 +154,26 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}),
|
||||
)
|
||||
|
||||
const isPending = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
if (message?.type === "user") return data.session.input.has(sessionID(), messageID)
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
const message = (event: { id: string; data: { sessionID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
}
|
||||
const input = (event: { data: { sessionID: string; inputID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.prompt.promoted", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
|
||||
}),
|
||||
data.on("session.prompt.admitted", input),
|
||||
data.on("session.compaction.started", message),
|
||||
data.on("session.instructions.updated", message),
|
||||
data.on("session.synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
||||
@@ -156,7 +182,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
data.on("session.shell.started", message),
|
||||
data.on("session.agent.selected", message),
|
||||
data.on("session.model.selected", message),
|
||||
|
||||
data.on("session.compaction.ended", (event) => {
|
||||
if (event.data.reason !== "manual") message(event)
|
||||
}),
|
||||
data.on("session.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||
@@ -196,11 +224,18 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[]) {
|
||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
|
||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
completePrevious(rows)
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -114,92 +114,24 @@ test("refreshes resources into reactive getters", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("pages older messages through nested message state", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_message_page"
|
||||
const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = []
|
||||
// Full first page (desc) so complete stays false until a short older page arrives.
|
||||
const first = Array.from({ length: 50 }, (_, index) => {
|
||||
const n = 51 - index
|
||||
return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } }
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||
pages.push({
|
||||
limit: url.searchParams.get("limit"),
|
||||
order: url.searchParams.get("order"),
|
||||
cursor: url.searchParams.get("cursor"),
|
||||
})
|
||||
if (!url.searchParams.get("cursor"))
|
||||
return json({
|
||||
data: first,
|
||||
cursor: { next: "cursor-older" },
|
||||
})
|
||||
return json({
|
||||
data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.message.refresh(sessionID)
|
||||
expect(pages).toEqual([{ limit: "25", order: "desc", cursor: null }])
|
||||
expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id))
|
||||
expect(data.session.message.cursor(sessionID)).toBe("cursor-older")
|
||||
expect(data.session.message.complete(sessionID)).toBe(false)
|
||||
expect(data.session.message.loading(sessionID)).toBe(false)
|
||||
|
||||
await data.session.message.more(sessionID)
|
||||
expect(pages).toEqual([
|
||||
{ limit: "25", order: "desc", cursor: null },
|
||||
{ limit: "25", order: null, cursor: "cursor-older" },
|
||||
])
|
||||
expect(data.session.message.ids(sessionID)[0]).toBe("msg_1")
|
||||
expect(data.session.message.ids(sessionID)).toHaveLength(51)
|
||||
expect(data.session.message.cursor(sessionID)).toBeUndefined()
|
||||
expect(data.session.message.complete(sessionID)).toBe(true)
|
||||
|
||||
await data.session.message.more(sessionID)
|
||||
expect(pages).toHaveLength(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("applies absolute usage events to session info", async () => {
|
||||
test("applies absolute usage events without losing full session updates", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_usage_refresh"
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const resolveSession: Array<(response: Response) => void> = []
|
||||
let sessionsRequested = false
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}`)
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Usage",
|
||||
location: { directory },
|
||||
},
|
||||
if (url.pathname === "/api/session") {
|
||||
sessionsRequested = true
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveSessions = resolve
|
||||
})
|
||||
}
|
||||
if (url.pathname === `/api/session/${sessionID}`) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveSession.push(resolve)
|
||||
})
|
||||
}
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
@@ -221,7 +153,7 @@ test("applies absolute usage events to session info", async () => {
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.refresh(sessionID)
|
||||
await wait(() => sessionsRequested)
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_2",
|
||||
created: 2,
|
||||
@@ -232,6 +164,38 @@ test("applies absolute usage events to session info", async () => {
|
||||
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
const initialRefresh = data.session.refresh(sessionID)
|
||||
await wait(() => resolveSession.length === 1)
|
||||
resolveSessions(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Stale usage",
|
||||
location: { directory },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
resolveSession[0](
|
||||
json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0.5,
|
||||
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Current usage",
|
||||
location: { directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await initialRefresh
|
||||
await wait(() => data.session.get(sessionID)?.cost === 0.5)
|
||||
expect(data.session.get(sessionID)?.tokens).toEqual({
|
||||
input: 5,
|
||||
@@ -240,6 +204,7 @@ test("applies absolute usage events to session info", async () => {
|
||||
cache: { read: 1, write: 1 },
|
||||
})
|
||||
|
||||
const fullRefresh = data.session.refresh(sessionID)
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_3",
|
||||
created: 3,
|
||||
@@ -251,8 +216,57 @@ test("applies absolute usage events to session info", async () => {
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 1)
|
||||
expect(data.session.get(sessionID)?.title).toBe("Usage")
|
||||
resolveSession[1](
|
||||
json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0.75,
|
||||
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Older usage",
|
||||
location: { directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await fullRefresh
|
||||
await Bun.sleep(20)
|
||||
expect(data.session.get(sessionID)?.cost).toBe(1)
|
||||
expect(data.session.get(sessionID)?.title).toBe("Older usage")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_6",
|
||||
created: 6,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1.25,
|
||||
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_7",
|
||||
created: 7,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1.25,
|
||||
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 1.25)
|
||||
expect(data.session.get(sessionID)?.title).toBe("Older usage")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_8",
|
||||
created: 8,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1.5,
|
||||
tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_deleted",
|
||||
created: 9,
|
||||
@@ -260,7 +274,8 @@ test("applies absolute usage events to session info", async () => {
|
||||
durable: durable(sessionID, 9, 2),
|
||||
data: { sessionID },
|
||||
})
|
||||
await wait(() => data.session.get(sessionID) === undefined)
|
||||
await Bun.sleep(20)
|
||||
expect(data.session.get(sessionID)).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -594,7 +609,15 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
expect(data.connection.error()).toBe("Event stream disconnected")
|
||||
|
||||
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
||||
resolveActive(json({ data: { "session-new": { type: "running" } } }))
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_started_after_reconnect",
|
||||
created: 1,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-new"),
|
||||
data: { sessionID: "session-new" },
|
||||
})
|
||||
await wait(() => data.session.status("session-new") === "running")
|
||||
resolveActive(json({ data: {} }))
|
||||
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
await wait(() => data.session.status("session-stale") === "idle")
|
||||
@@ -608,18 +631,16 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps pending prompts out of history rows until promoted", async () => {
|
||||
test("completes exploration when a queued prompt is promoted", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-promotion"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
rows = createSessionRows(() => sessionID)
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
@@ -674,8 +695,7 @@ test("keeps pending prompts out of history rows until promoted", async () => {
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.input.has(sessionID, "message-user"))
|
||||
expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false)
|
||||
await wait(() => rows.at(-1)?.type === "message")
|
||||
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
|
||||
|
||||
emitEvent(events, {
|
||||
@@ -685,8 +705,7 @@ test("keeps pending prompts out of history rows until promoted", async () => {
|
||||
durable: durable(sessionID, 3),
|
||||
data: { sessionID, inputID: "message-user" },
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user"))
|
||||
expect(data.session.input.has(sessionID, "message-user")).toBe(false)
|
||||
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
|
||||
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
||||
@@ -207,25 +207,31 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
test("history reduce keeps chronological order without pending reordering", () => {
|
||||
test("places a running compaction barrier before every queued user message", () => {
|
||||
const queued = (id: string, text: string, created: number): SessionMessageInfo => ({
|
||||
type: "user",
|
||||
id,
|
||||
text,
|
||||
time: { created },
|
||||
})
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Before", time: { created: 1 } },
|
||||
queued("user-before", "Before", 1),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction",
|
||||
status: "completed",
|
||||
status: "running",
|
||||
reason: "manual",
|
||||
summary: "done",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 2 },
|
||||
},
|
||||
{ type: "user", id: "user-2", text: "After", time: { created: 3 } },
|
||||
queued("user-after", "After", 3),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
|
||||
{ type: "message", messageID: "compaction" },
|
||||
{ type: "message", messageID: "user-2" },
|
||||
{ type: "message", messageID: "user-before" },
|
||||
{ type: "message", messageID: "user-after" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -69,8 +69,7 @@ export type FetchHandler = (url: URL) => Response | Promise<Response> | undefine
|
||||
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
|
||||
const session = [] as URL[]
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const request = input instanceof Request ? input : new Request(input)
|
||||
const url = new URL(request.url)
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
if (url.pathname === "/session") session.push(url)
|
||||
const overridden = await override?.(url)
|
||||
if (overridden) return overridden
|
||||
@@ -118,10 +117,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
})
|
||||
if (url.pathname === "/api/reference")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
if (url.pathname === "/api/websearch/provider") {
|
||||
if (request.method === "POST") return new Response(null, { status: 204 })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: null })
|
||||
}
|
||||
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/session") return json([])
|
||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||
|
||||
Reference in New Issue
Block a user