diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 2deec51a22..c09d7c8ed2 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/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. diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts index 14f54d457a..392302d429 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts +++ b/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), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index ad686ba08c..b99aa948e8 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/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)) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index e704750eaf..84f8cef65f 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/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" }