adapter.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import { Tool } from "@opencode-ai/schema/tool"
  2. import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
  3. import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
  4. import { define } from "../effect/plugin.js"
  5. import type { PluginEventType } from "./event.js"
  6. import type { Context, Plugin } from "./plugin.js"
  7. import type { Info } from "./tool.js"
  8. type HostRegistration = { readonly dispose: Effect.Effect<void> }
  9. type Registration = { readonly dispose: () => Promise<void> }
  10. type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
  11. interface CompiledEndpoint {
  12. readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
  13. readonly encode: (output: unknown) => Effect.Effect<unknown, Schema.SchemaError>
  14. readonly noContent: boolean
  15. }
  16. const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
  17. function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
  18. const cached = compiledEndpoints.get(endpoint)
  19. if (cached) return cached
  20. const payloadSchemas = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
  21. const successSchemas = Array.from(endpoint.success)
  22. if (payloadSchemas.length > 1 || successSchemas.length > 1) {
  23. throw new Error(`Unsupported API schema cardinality: ${endpoint.identifier}`)
  24. }
  25. const inputs = [
  26. endpoint.params,
  27. endpoint.query === undefined ? undefined : Schema.toType(endpoint.query),
  28. endpoint.headers,
  29. ...payloadSchemas,
  30. ].filter((schema): schema is Schema.Top => schema !== undefined) as Array<RuntimeSchema>
  31. const success = (successSchemas[0] ?? HttpApiSchema.NoContent) as RuntimeSchema
  32. const noContent = HttpApiSchema.isNoContent(success.ast)
  33. const type = Schema.toType(success).ast
  34. const data = SchemaAST.isObjects(success.ast)
  35. ? success.ast.propertySignatures.find((property) => property.name === "data")
  36. : undefined
  37. const output =
  38. !noContent &&
  39. SchemaAST.isObjects(type) &&
  40. type.indexSignatures.length === 0 &&
  41. type.propertySignatures.length === 1 &&
  42. type.propertySignatures[0]?.name === "data" &&
  43. data !== undefined
  44. ? (Schema.make<Schema.Top>(data.type) as RuntimeSchema)
  45. : success
  46. const compiled = {
  47. decode: inputs.map((schema) => Schema.decodeUnknownEffect(schema)),
  48. encode: Schema.encodeUnknownEffect(output),
  49. noContent,
  50. } satisfies CompiledEndpoint
  51. compiledEndpoints.set(endpoint, compiled)
  52. return compiled
  53. }
  54. /**
  55. * Adapts a Promise plugin into an Effect plugin so the existing Effect-only
  56. * loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
  57. *
  58. * Hook registrations created during the async `setup` attach to the plugin's
  59. * scope, so unloading the plugin disposes them. The captured fiber context
  60. * preserves boot-time batching, so Promise-plugin transforms still coalesce
  61. * into one reload per domain.
  62. */
  63. export function fromPromise(plugin: Plugin) {
  64. return define({
  65. id: plugin.id,
  66. effect: (host) =>
  67. Effect.gen(function* () {
  68. const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
  69. Promise.all([import("@opencode-ai/protocol/client"), import("@opencode-ai/protocol/groups/event")]),
  70. )
  71. const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
  72. const CommandEndpoints = ClientApi.groups["server.command"].endpoints
  73. const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
  74. const ModelEndpoints = ClientApi.groups["server.model"].endpoints
  75. const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
  76. const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
  77. const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
  78. const SessionEndpoints = ClientApi.groups["server.session"].endpoints
  79. const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
  80. const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
  81. const scope = yield* Scope.Scope
  82. const context = yield* Effect.context<Scope.Scope>()
  83. // Run a hook registration on the plugin scope and resolve once it is registered.
  84. const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
  85. Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
  86. dispose: () => Effect.runPromiseWith(context)(registration.dispose),
  87. }))
  88. const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
  89. const adaptApiMethod = <PromiseMethod>(
  90. endpoint: HttpApiEndpoint.Top,
  91. method: (input: never) => Effect.Effect<unknown, unknown>,
  92. ) => {
  93. const compiled = compileEndpoint(endpoint)
  94. return ((input?: unknown) =>
  95. Effect.gen(function* () {
  96. const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
  97. const result = yield* method(Object.assign({}, ...decoded) as never)
  98. if (compiled.noContent) return undefined
  99. return yield* compiled.encode(result)
  100. }).pipe(Effect.runPromiseWith(context))) as PromiseMethod
  101. }
  102. const transform =
  103. <Draft>(domain: {
  104. transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
  105. }) =>
  106. (callback: (draft: Draft) => void) =>
  107. register(
  108. domain.transform((draft) => {
  109. callback(draft)
  110. }),
  111. )
  112. const context2: Context = {
  113. app: host.app,
  114. options: host.options,
  115. agent: {
  116. get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
  117. list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
  118. transform: transform(host.agent),
  119. reload: () => run(host.agent.reload()),
  120. },
  121. aisdk: {
  122. hook: (name, callback) =>
  123. register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
  124. },
  125. catalog: {
  126. provider: {
  127. list: adaptApiMethod(ProviderEndpoints["provider.list"], host.catalog.provider.list),
  128. get: adaptApiMethod(ProviderEndpoints["provider.get"], host.catalog.provider.get),
  129. },
  130. model: {
  131. list: adaptApiMethod(ModelEndpoints["model.list"], host.catalog.model.list),
  132. default: adaptApiMethod(ModelEndpoints["model.default"], host.catalog.model.default),
  133. },
  134. transform: transform(host.catalog),
  135. reload: () => run(host.catalog.reload()),
  136. },
  137. command: {
  138. list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
  139. transform: transform(host.command),
  140. reload: () => run(host.command.reload()),
  141. },
  142. event: {
  143. subscribe: (type?: PluginEventType) => {
  144. const events = type === undefined ? host.event.subscribe() : host.event.subscribe(type)
  145. return Stream.toAsyncIterable(
  146. events.pipe(
  147. Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
  148. Stream.map((event) => event as unknown as PromiseEvent),
  149. ),
  150. )
  151. },
  152. },
  153. integration: {
  154. list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
  155. get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
  156. connect: {
  157. key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
  158. },
  159. oauth: {
  160. connect: adaptApiMethod(
  161. IntegrationEndpoints["integration.oauth.connect"],
  162. host.integration.oauth.connect,
  163. ),
  164. status: adaptApiMethod(IntegrationEndpoints["integration.oauth.status"], host.integration.oauth.status),
  165. complete: adaptApiMethod(
  166. IntegrationEndpoints["integration.oauth.complete"],
  167. host.integration.oauth.complete,
  168. ),
  169. cancel: adaptApiMethod(IntegrationEndpoints["integration.oauth.cancel"], host.integration.oauth.cancel),
  170. },
  171. command: {
  172. connect: adaptApiMethod(
  173. IntegrationEndpoints["integration.command.connect"],
  174. host.integration.command.connect,
  175. ),
  176. status: adaptApiMethod(
  177. IntegrationEndpoints["integration.command.status"],
  178. host.integration.command.status,
  179. ),
  180. cancel: adaptApiMethod(
  181. IntegrationEndpoints["integration.command.cancel"],
  182. host.integration.command.cancel,
  183. ),
  184. },
  185. transform: (callback) =>
  186. register(
  187. host.integration.transform((draft) =>
  188. callback({
  189. list: draft.list,
  190. get: draft.get,
  191. update: draft.update,
  192. remove: draft.remove,
  193. method: {
  194. list: draft.method.list,
  195. update: (input) => {
  196. if (!("authorize" in input)) return draft.method.update(input)
  197. const refresh = input.refresh
  198. draft.method.update({
  199. ...input,
  200. authorize: (answer) =>
  201. Effect.promise(() => input.authorize(answer)).pipe(
  202. Effect.map((authorization) =>
  203. authorization.mode === "auto"
  204. ? {
  205. ...authorization,
  206. callback: Effect.promise(() => authorization.callback),
  207. }
  208. : {
  209. ...authorization,
  210. callback: (code) => Effect.promise(() => authorization.callback(code)),
  211. },
  212. ),
  213. ),
  214. refresh:
  215. refresh === undefined
  216. ? undefined
  217. : (credential) => Effect.promise(() => refresh(credential)),
  218. })
  219. },
  220. remove: draft.method.remove,
  221. },
  222. }),
  223. ),
  224. ),
  225. reload: () => run(host.integration.reload()),
  226. connection: {
  227. active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
  228. resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
  229. },
  230. },
  231. plugin: {
  232. list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
  233. },
  234. reference: {
  235. list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
  236. transform: transform(host.reference),
  237. reload: () => run(host.reference.reload()),
  238. },
  239. skill: {
  240. list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
  241. transform: transform(host.skill),
  242. reload: () => run(host.skill.reload()),
  243. },
  244. tool: {
  245. transform: (callback) =>
  246. register(
  247. host.tool.transform((draft) =>
  248. callback({
  249. add: (tool: Info) =>
  250. draft.add({
  251. ...tool,
  252. execute: (input, context) => executePromiseTool(tool, input, context),
  253. }),
  254. }),
  255. ),
  256. ),
  257. hook: (name, callback) =>
  258. register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
  259. },
  260. websearch: {
  261. providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
  262. query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query),
  263. reload: () => run(host.websearch.reload()),
  264. transform: (callback) =>
  265. register(
  266. host.websearch.transform((draft) => {
  267. callback({
  268. add: (definition) =>
  269. draft.add({
  270. id: definition.id,
  271. name: definition.name,
  272. execute: (input) => attempt((signal) => definition.execute(input, { signal })),
  273. }),
  274. default: draft.default,
  275. })
  276. }),
  277. ),
  278. },
  279. session: {
  280. hook: (name, callback) =>
  281. register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
  282. create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
  283. get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
  284. prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
  285. generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
  286. command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
  287. synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
  288. interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
  289. rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
  290. wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
  291. },
  292. shell: {
  293. hook: (name, callback) =>
  294. register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
  295. },
  296. }
  297. const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
  298. if (!cleanup) return
  299. yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
  300. }),
  301. })
  302. }
  303. function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
  304. return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
  305. }
  306. type RuntimeSchema = Schema.Codec<unknown, unknown>
  307. const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
  308. Effect.promise(() =>
  309. tool.execute(input, {
  310. ...context,
  311. progress: (update) => Effect.runPromise(context.progress(update)),
  312. }),
  313. )