Sfoglia il codice sorgente

feat(httpapi): bridge experimental tool routes (#24407)

Kit Langton 3 mesi fa
parent
commit
7cab6824d1

+ 4 - 4
packages/opencode/specs/effect/http-api.md

@@ -181,7 +181,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `mcp`                     | `bridged`         | status, add, OAuth, connect/disconnect                                                             |
 | `workspace`               | `bridged` partial | adaptor/list/status; create/remove/session-restore remain                                          |
 | top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                                          |
-| experimental JSON routes  | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
+| experimental JSON routes  | `bridged` partial | console, tool, worktree list/mutations, resource list; global session list remains later          |
 | `session`                 | `later/special`   | large stateful surface plus streaming                                                              |
 | `sync`                    | `later`           | process/control side effects                                                                       |
 | `event`                   | `special`         | SSE                                                                                                |
@@ -259,9 +259,9 @@ This checklist tracks bridge parity only. Checked routes are available through t
 
 - [x] `GET /experimental/console` - active Console provider metadata.
 - [x] `GET /experimental/console/orgs` - switchable Console orgs.
-- [ ] `POST /experimental/console/switch` - switch active Console org.
+- [x] `POST /experimental/console/switch` - switch active Console org.
 - [x] `GET /experimental/tool/ids` - tool IDs.
-- [ ] `GET /experimental/tool` - tools for provider/model.
+- [x] `GET /experimental/tool` - tools for provider/model.
 - [x] `GET /experimental/worktree` - list worktrees.
 - [x] `POST /experimental/worktree` - create worktree.
 - [x] `DELETE /experimental/worktree` - remove worktree.
@@ -350,7 +350,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
 1. [x] Bridge `PATCH /project/:projectID`.
 2. [x] Bridge MCP add/connect/disconnect routes.
 3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
-4. [ ] Bridge experimental console switch and tool list routes.
+4. [x] Bridge experimental console switch and tool list routes.
 5. [ ] Bridge experimental global session list.
 6. [ ] Bridge workspace create/remove/session-restore routes.
 7. [ ] Bridge sync start/replay/history routes.

+ 69 - 1
packages/opencode/src/server/routes/instance/httpapi/experimental.ts

@@ -1,12 +1,16 @@
 import { Account } from "@/account/account"
+import { AccountID, OrgID } from "@/account/schema"
+import { Agent } from "@/agent/agent"
 import { Config } from "@/config"
 import { InstanceState } from "@/effect"
 import { MCP } from "@/mcp"
 import { Project } from "@/project"
+import { ProviderID, ModelID } from "@/provider/schema"
 import { ToolRegistry } from "@/tool"
+import * as EffectZod from "@/util/effect-zod"
 import { Worktree } from "@/worktree"
 import { Effect, Layer, Option, Schema } from "effect"
-import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "./auth"
 
 const ConsoleStateResponse = Schema.Struct({
@@ -28,13 +32,30 @@ const ConsoleOrgList = Schema.Struct({
   orgs: Schema.Array(ConsoleOrgOption),
 }).annotate({ identifier: "ConsoleOrgList" })
 
+const ConsoleSwitchPayload = Schema.Struct({
+  accountID: AccountID,
+  orgID: OrgID,
+}).annotate({ identifier: "ConsoleSwitchInput" })
+
 const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
+const ToolListItem = Schema.Struct({
+  id: Schema.String,
+  description: Schema.String,
+  parameters: Schema.Record(Schema.String, Schema.Any),
+}).annotate({ identifier: "ToolListItem" })
+const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
+const ToolListQuery = Schema.Struct({
+  provider: ProviderID,
+  model: ModelID,
+})
 
 const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
 
 export const ExperimentalPaths = {
   console: "/experimental/console",
   consoleOrgs: "/experimental/console/orgs",
+  consoleSwitch: "/experimental/console/switch",
+  tool: "/experimental/tool",
   toolIDs: "/experimental/tool/ids",
   worktree: "/experimental/worktree",
   worktreeReset: "/experimental/worktree/reset",
@@ -63,6 +84,27 @@ export const ExperimentalApi = HttpApi.make("experimental")
             description: "Get the available Console orgs across logged-in accounts, including the current active org.",
           }),
         ),
+        HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
+          payload: ConsoleSwitchPayload,
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.console.switchOrg",
+            summary: "Switch active Console org",
+            description: "Persist a new active Console account/org selection for the current local OpenCode state.",
+          }),
+        ),
+        HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
+          query: ToolListQuery,
+          success: ToolList,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "tool.list",
+            summary: "List tools",
+            description:
+              "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
+          }),
+        ),
         HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
           success: ToolIDs,
         }).annotateMerge(
@@ -141,6 +183,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
 export const experimentalHandlers = Layer.unwrap(
   Effect.gen(function* () {
     const account = yield* Account.Service
+    const agents = yield* Agent.Service
     const config = yield* Config.Service
     const mcp = yield* MCP.Service
     const project = yield* Project.Service
@@ -183,6 +226,28 @@ export const experimentalHandlers = Layer.unwrap(
       }
     })
 
+    const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
+      payload: typeof ConsoleSwitchPayload.Type
+    }) {
+      yield* account
+        .use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
+        .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
+      return true
+    })
+
+    const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
+      const list = yield* registry.tools({
+        providerID: ctx.query.provider,
+        modelID: ctx.query.model,
+        agent: yield* agents.get(yield* agents.defaultAgent()),
+      })
+      return list.map((item) => ({
+        id: item.id,
+        description: item.description,
+        parameters: EffectZod.toJsonSchema(item.parameters),
+      }))
+    })
+
     const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
       return yield* registry.ids()
     })
@@ -222,6 +287,8 @@ export const experimentalHandlers = Layer.unwrap(
       handlers
         .handle("console", getConsole)
         .handle("consoleOrgs", listConsoleOrgs)
+        .handle("consoleSwitch", switchConsole)
+        .handle("tool", tool)
         .handle("toolIDs", toolIDs)
         .handle("worktree", worktree)
         .handle("worktreeCreate", worktreeCreate)
@@ -232,6 +299,7 @@ export const experimentalHandlers = Layer.unwrap(
   }),
 ).pipe(
   Layer.provide(Account.defaultLayer),
+  Layer.provide(Agent.defaultLayer),
   Layer.provide(Config.defaultLayer),
   Layer.provide(MCP.defaultLayer),
   Layer.provide(Project.defaultLayer),

+ 2 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -49,6 +49,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.get("/config/providers", (c) => handler(c.req.raw, context))
     app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
     app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
+    app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
     app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
     app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
     app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))

+ 33 - 2
packages/opencode/test/server/httpapi-experimental.test.ts

@@ -5,6 +5,7 @@ import { GlobalBus } from "@/bus/global"
 import { Instance } from "../../src/project/instance"
 import { InstanceRoutes } from "../../src/server/routes/instance"
 import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
+import { Database } from "../../src/storage"
 import { Log } from "../../src/util"
 import { Worktree } from "../../src/worktree"
 import { resetDatabase } from "../fixture/db"
@@ -14,6 +15,7 @@ void Log.init({ print: false })
 
 const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
 const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+const testWorktreeMutations = process.platform === "win32" ? test.skip : test
 
 function app() {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
@@ -61,9 +63,10 @@ describe("experimental HttpApi", () => {
     })
 
     const headers = { "x-opencode-directory": tmp.path }
-    const [consoleState, consoleOrgs, toolIDs, worktrees, resources] = await Promise.all([
+    const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = await Promise.all([
       app().request(ExperimentalPaths.console, { headers }),
       app().request(ExperimentalPaths.consoleOrgs, { headers }),
+      app().request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, { headers }),
       app().request(ExperimentalPaths.toolIDs, { headers }),
       app().request(ExperimentalPaths.worktree, { headers }),
       app().request(ExperimentalPaths.resource, { headers }),
@@ -78,6 +81,15 @@ describe("experimental HttpApi", () => {
     expect(consoleOrgs.status).toBe(200)
     expect(await consoleOrgs.json()).toEqual({ orgs: [] })
 
+    expect(toolList.status).toBe(200)
+    expect(await toolList.json()).toContainEqual(
+      expect.objectContaining({
+        id: "bash",
+        description: expect.any(String),
+        parameters: expect.any(Object),
+      }),
+    )
+
     expect(toolIDs.status).toBe(200)
     expect(await toolIDs.json()).toContain("bash")
 
@@ -88,7 +100,26 @@ describe("experimental HttpApi", () => {
     expect(await resources.json()).toEqual({})
   })
 
-  test("serves worktree mutations through Hono bridge", async () => {
+  test("serves Console org switch through Hono bridge", async () => {
+    await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
+    Database.Client()
+      .$client
+      .prepare(
+        "INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
+      )
+      .run("account-test", "test@example.com", "https://console.example.com", "access", "refresh", Date.now(), Date.now())
+
+    const switched = await app().request(ExperimentalPaths.consoleSwitch, {
+      method: "POST",
+      headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
+      body: JSON.stringify({ accountID: "account-test", orgID: "org-test" }),
+    })
+
+    expect(switched.status).toBe(200)
+    expect(await switched.json()).toBe(true)
+  })
+
+  testWorktreeMutations("serves worktree mutations through Hono bridge", async () => {
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
 
     const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }