Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline d7c183e988 feat(core): add Cloudflare connection prompts 2026-08-06 13:31:59 -05:00
Aiden Cline be2f74d44a refactor(plugin): split session HTTP hooks (#40724) 2026-08-06 13:23:22 -05:00
James Long 912a801060 refactor(drizzle): declare query variance (#40891) 2026-08-06 13:58:17 -04:00
Dax 0215498f63 fix(core): load well-known config first (#40897) 2026-08-06 10:44:26 -07:00
37 changed files with 354 additions and 351 deletions
@@ -561,7 +561,13 @@ function ProviderConnection(props: {
if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
return
}
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs: inputs ?? {} })
}
function AuthPromptsView() {
@@ -572,7 +578,7 @@ function ProviderConnection(props: {
const prompts = createMemo(() => {
const value = method()
return value?.type === "oauth" ? (value.prompts ?? []) : []
return value?.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
@@ -820,6 +826,7 @@ function ProviderConnection(props: {
integrationID: props.provider,
location: location(),
key: apiKey,
inputs: store.promptInputs ?? {},
})
await complete()
}
+1
View File
@@ -992,6 +992,7 @@ export type Endpoint10_3Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}
export type Endpoint10_3Output = void
@@ -664,7 +664,7 @@ const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint1
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
payload: { key: input["key"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -963,7 +963,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
body: { key: input["key"], inputs: input["inputs"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+23 -20
View File
@@ -197,8 +197,6 @@ export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
export type IntegrationKeyMethod = { type: "key"; label?: string }
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
@@ -1638,6 +1636,12 @@ export type IntegrationOAuthMethod = {
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type IntegrationKeyMethod = {
type: "key"
label?: string
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type FormField =
| FormStringField
| FormNumberField
@@ -1937,23 +1941,9 @@ export type IntegrationInfo = {
connections: Array<ConnectionInfo>
}
export type FormInfo = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata
fields: FormFields
}
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
export type FormInfo1 = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata1
fields: FormFields1
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
export type SessionInputAdmitted = {
id: string
@@ -3133,8 +3123,21 @@ export type IntegrationConnectKeyInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
readonly key: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["key"]
readonly inputs?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["inputs"]
readonly label?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["label"]
}
export type IntegrationConnectKeyOutput = void
+1 -1
View File
@@ -233,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...(supplementary[0] ?? []),
...explicit,
...direct,
...supplementary.slice(1).flat(),
...(yield* loadWellknown().pipe(Effect.orDie)),
...content,
]
})
-1
View File
@@ -132,7 +132,6 @@ export const layer = Layer.effect(
id,
sessionID: input.sessionID,
title: input.title,
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields,
}
+7 -1
View File
@@ -175,6 +175,8 @@ export interface Interface extends State.Transformable<Draft> {
readonly integrationID: ID
/** Secret entered by the user. */
readonly key: string
/** Provider-specific values collected before the secret. */
readonly inputs?: Inputs
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
@@ -704,7 +706,11 @@ const layer = Layer.effect(
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({ type: "key", key: input.key }),
value: Credential.Key.make({
type: "key",
key: input.key,
metadata: input.inputs && Object.keys(input.inputs).length > 0 ? input.inputs : undefined,
}),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
+2 -1
View File
@@ -192,6 +192,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
inputs: input.inputs,
label: input.label,
}),
},
@@ -398,7 +399,7 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
}
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
method: { type: "key", label: input.method.label, prompts: input.method.prompts },
}
}
+2 -58
View File
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
}),
)
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = {
app: host.app,
options: host.options,
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: sessionHook,
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) =>
run(
host.session.create(
@@ -6,6 +6,37 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update({
integrationID: "cloudflare-ai-gateway",
method: {
type: "key",
label: "Gateway API token",
prompts: [
...(process.env.CLOUDFLARE_ACCOUNT_ID
? []
: [
{
type: "text" as const,
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
]),
...(process.env.CLOUDFLARE_GATEWAY_ID
? []
: [
{
type: "text" as const,
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
]),
],
},
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -9,6 +9,25 @@ const providerID = Provider.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update({
integrationID: providerID,
method: {
type: "key",
label: "API key",
prompts: process.env.CLOUDFLARE_ACCOUNT_ID
? undefined
: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
},
})
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
+8 -8
View File
@@ -225,14 +225,14 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("http", (evt) =>
evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(request.url)
request.headers.set("originator", "opencode")
request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return next(request)
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
yield* ctx.session.hook("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
}),
)
+21 -35
View File
@@ -2,7 +2,6 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
const options: StreamOptions = {
http: (request, handler) =>
Effect.gen(function* () {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
const before = yield* hooks.trigger("session", "http.request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
request: yield* HttpClientRequest.toWeb(request),
})
const send = (input: Request) =>
Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
latest = sent
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
@@ -59,7 +59,6 @@ export const Plugin = {
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
coalesce: `${context.messageID}:websearch-consent`,
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -92,7 +91,6 @@ export const Plugin = {
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
coalesce: `${context.messageID}:websearch-provider`,
metadata: { kind: "websearch.provider" },
fields: [
{
-2
View File
@@ -15,7 +15,6 @@ const input = {
id: formID,
sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form",
coalesce: "test-form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
@@ -33,7 +32,6 @@ describe("Form", () => {
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.coalesce).toBe("test-form")
yield* service.cancel(form.id)
+2 -1
View File
@@ -151,6 +151,7 @@ describe("Integration", () => {
yield* integrations.connection.key({
integrationID,
key: "secret",
inputs: { accountId: "account" },
label: "Work",
})
@@ -158,7 +159,7 @@ describe("Integration", () => {
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account" } }),
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
+21 -78
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
@@ -223,102 +223,45 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
it.effect("adapts promise session HTTP request and response hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
await ctx.session.hook("http.request", (event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
const context = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
const request = yield* hooks.trigger("session", "http.request", {
...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
})
const response = yield* hooks.trigger("session", "http.response", {
...context,
request: request.request,
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
})
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use((request, next) => next(request))
})
},
}),
).effect(host)
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
)
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
expect(request.request.url).toBe("https://provider.test/changed")
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
}),
)
@@ -6,6 +6,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -102,6 +103,39 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
}))
describe("CloudflareAIGatewayPlugin", () => {
it.effect("prompts for account and gateway IDs when the environment does not provide them", () =>
withEnv(
cloudflareEnv({
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_GATEWAY_ID: undefined,
}),
() =>
Effect.gen(function* () {
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
).toContainEqual({
type: "key",
label: "Gateway API token",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
{
type: "text",
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
],
})
}),
),
)
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
withEnv(
{
@@ -7,6 +7,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -79,6 +80,28 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
}
describe("CloudflareWorkersAIPlugin", () => {
it.effect("prompts for the account ID when the environment does not provide it", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
Effect.gen(function* () {
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
).toContainEqual({
type: "key",
label: "API key",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
})
}),
),
)
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
@@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -31,26 +30,13 @@ function required<T>(value: T | undefined): T {
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
request: new Request(url, { method: "POST", body: "{}" }),
})
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
})
describe("OpenAIPlugin", () => {
@@ -255,6 +255,7 @@ describe("SessionRunnerLLM recorded", () => {
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const headers: Array<string | undefined> = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
@@ -268,6 +269,7 @@ describe("SessionModelRequest HTTP bridge", () => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
headers.push(request.headers["x-hook"])
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
@@ -275,14 +277,16 @@ describe("SessionModelRequest HTTP bridge", () => {
}),
),
)
const retryIt = testEffect(
const httpIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
headers.length = 0
const seen: string[] = []
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
@@ -297,13 +301,20 @@ describe("SessionModelRequest HTTP bridge", () => {
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http", (event) =>
event.use((request, next) =>
Effect.gen(function* () {
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
return yield* next(request)
}),
),
yield* pluginHost.session.hook("http.request", (event) =>
Effect.sync(() => {
seen.push("request")
event.request.headers.set("x-hook", "effect")
}),
)
yield* pluginHost.session.hook("http.response", (event) =>
Effect.gen(function* () {
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
event.response = new Response(
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
event.response,
)
}),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
@@ -331,10 +342,15 @@ describe("SessionModelRequest HTTP bridge", () => {
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(methods).toEqual(["POST"])
expect(headers).toEqual(["effect"])
expect(seen).toEqual(["request", "response:200:effect"])
expect(bodies).toHaveLength(1)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect(bodies[1]).toEqual(bodies[0])
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
type: "assistant",
content: [{ type: "text", text: "Hooked!" }],
})
}),
)
})
@@ -241,7 +241,6 @@ describe("WebSearchTool registration", () => {
{
sessionID,
title: "Web Search",
coalesce: "msg_tool_test:websearch-consent",
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -299,7 +298,6 @@ describe("WebSearchTool registration", () => {
expect(formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
coalesce: "msg_tool_test:websearch-provider",
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -44,11 +44,12 @@ export type SQLiteEffectSelectPrepare<
TEffectHKT
>
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
export class SQLiteEffectSelectBuilder<
TSelection extends SelectedFields | undefined,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TBuilderMode extends "db" | "qb" = "db",
out TSelection extends SelectedFields | undefined,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TBuilderMode extends "db" | "qb" = "db",
> {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
@@ -303,10 +303,11 @@ export class SQLiteEffectPreparedQuery<
}
}
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
export abstract class SQLiteEffectSession<
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TRunResult = unknown,
TRelations extends AnyRelations = EmptyRelations,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TRunResult = unknown,
out TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
@@ -404,9 +405,9 @@ export abstract class SQLiteEffectSession<
}
export abstract class SQLiteEffectTransaction<
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
out TEffectHKT extends QueryEffectHKTBase,
out TRunResult,
out TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
+12 -10
View File
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect, JsonSchema } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,23 +15,25 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
request: Request
}
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
}
export type SessionDomain = Pick<
+11 -9
View File
@@ -15,23 +15,25 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => void
request: Request
}
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
}
export type SessionDomain = Pick<
@@ -59,6 +59,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
query: LocationQuery,
payload: Schema.Struct({
key: Schema.String,
inputs: Schema.optional(Inputs),
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
-3
View File
@@ -124,9 +124,6 @@ const InfoBase = {
// on non-session owners anywhere else.
sessionID: Schema.String,
title: Schema.String,
coalesce: Schema.String.pipe(optional).annotate({
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
}),
metadata: Metadata.pipe(optional),
}
+1
View File
@@ -68,6 +68,7 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: optional(Schema.String),
prompts: optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.KeyMethod" })
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
@@ -58,6 +58,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
service.connection.key({
integrationID: ctx.params.integrationID,
key: ctx.payload.key,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
)
@@ -180,7 +180,7 @@ function openMethod(
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
void beginKey(integration, method, dialog, onConnected)
return
}
if (method.type === "command") {
@@ -190,6 +190,19 @@ function openMethod(
void beginOAuth(integration, method, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
))
}
function CommandStarting(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "command" }>
@@ -335,6 +348,7 @@ function CommandView(props: { title: string; output: string; message: string })
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
inputs: Record<string, string>
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -355,6 +369,7 @@ function KeyMethod(props: {
integrationID: props.integration.id,
location: location(data),
key,
inputs: props.inputs,
})
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
+2 -2
View File
@@ -93,13 +93,13 @@ export function Home() {
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" input={{}} mode="replace" />
</box>
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? (
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} forms={forms()} />
<FormPrompt form={form} />
</box>
</box>
) : null
+41 -39
View File
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
}
}
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const themes = useThemes()
const theme = useTheme("elevated")
@@ -69,11 +69,6 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | undefined
const forms = createMemo(() => {
if (!props.form.coalesce) return [props.form]
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
})
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
return typeof value === "string" ? value : undefined
@@ -185,30 +180,24 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
setStore("error", "")
}
function reply(answer: Record<string, FormValue>) {
Promise.all(
forms().map((form) =>
client.api.form.reply(
{
sessionID: form.sessionID,
formID: form.id,
answer,
},
requestOptions(form),
),
),
).catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
function replySingle(field: FormAnswerField, value: FormValue) {
reply({ [field.key]: value })
client.api.form
.reply(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: { [field.key]: value },
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
function pick(value: FormValue, customValue?: string) {
@@ -361,8 +350,7 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
}
function cancel() {
for (const form of forms())
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
}
function openExternal() {
@@ -414,14 +402,28 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
return
}
reply(
Object.fromEntries(
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
),
)
client.api.form
.reply(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: Object.fromEntries(
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
),
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
+2 -2
View File
@@ -1026,10 +1026,10 @@ export function Session() {
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} forms={forms()} /> : null
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
+3 -25
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, coalesce = false) {
async function mountForm(root: string, width = 80) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80, coalesce = false) {
const events = createEventStream()
const transport = createFetch(
(url, request) =>
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
url.pathname === "/api/session/ses_test/form/frm_test/reply"
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
@@ -37,7 +37,6 @@ async function mountForm(root: string, width = 80, coalesce = false) {
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
...(coalesce ? { coalesce: "authorization" } : {}),
fields: [
{
key: "authorization",
@@ -72,7 +71,7 @@ async function mountForm(root: string, width = 80, coalesce = false) {
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</ClientProvider>
@@ -127,24 +126,3 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy()
}
})
test("replies to every coalesced form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, true)
try {
prompt.app.mockInput.pressKey("right")
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
prompt.app.mockInput.pressKey("left")
prompt.app.mockInput.pressKey("c")
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 2)
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
} finally {
prompt.app.renderer.destroy()
}
})
+16 -8
View File
@@ -246,19 +246,27 @@ Runtime hooks intercept live operations:
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
HTTP hooks can modify requests, inspect responses, retry, or return a
response without calling the provider. It applies to native models; AI SDK
models do not currently pass through this hook.
HTTP hooks can modify requests and responses. They apply to native models; AI
SDK models do not currently pass through these hooks. Request and response
bodies are one-shot streams. Use `clone()` when you intentionally need a
separate reader, but be aware that its slower branch may buffer data. To inspect
or modify chunks while preserving streaming, replace the body with one piped
through a `TransformStream`.
```ts
await ctx.session.hook("http", (event) => {
event.use((request, next) => {
request.headers.set("x-session-id", event.sessionID)
return next(request)
await ctx.session.hook("http.request", (event) => {
event.request.headers.set("x-session-id", event.sessionID)
})
await ctx.session.hook("http.response", (event) => {
event.response = new Response(event.response.body, {
status: event.response.status,
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
})
})
```