mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b60294c696 | |||
| 5a4763f560 | |||
| 2a600797fb | |||
| 9175c68a77 |
@@ -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 |
|
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||||
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
||||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
| 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 |
|
| `session` | `later/special` | large stateful surface plus streaming |
|
||||||
| `sync` | `later` | process/control side effects |
|
| `sync` | `later` | process/control side effects |
|
||||||
| `event` | `special` | SSE |
|
| `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` - active Console provider metadata.
|
||||||
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
|
- [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.
|
- [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] `GET /experimental/worktree` - list worktrees.
|
||||||
- [x] `POST /experimental/worktree` - create worktree.
|
- [x] `POST /experimental/worktree` - create worktree.
|
||||||
- [x] `DELETE /experimental/worktree` - remove 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`.
|
1. [x] Bridge `PATCH /project/:projectID`.
|
||||||
2. [x] Bridge MCP add/connect/disconnect routes.
|
2. [x] Bridge MCP add/connect/disconnect routes.
|
||||||
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
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.
|
5. [ ] Bridge experimental global session list.
|
||||||
6. [ ] Bridge workspace create/remove/session-restore routes.
|
6. [ ] Bridge workspace create/remove/session-restore routes.
|
||||||
7. [ ] Bridge sync start/replay/history routes.
|
7. [ ] Bridge sync start/replay/history routes.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Account } from "@/account/account"
|
import { Account } from "@/account/account"
|
||||||
|
import { AccountID, OrgID } from "@/account/schema"
|
||||||
|
import { Agent } from "@/agent/agent"
|
||||||
import { Config } from "@/config"
|
import { Config } from "@/config"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Project } from "@/project"
|
import { Project } from "@/project"
|
||||||
|
import { ProviderID, ModelID } from "@/provider/schema"
|
||||||
import { ToolRegistry } from "@/tool"
|
import { ToolRegistry } from "@/tool"
|
||||||
|
import * as EffectZod from "@/util/effect-zod"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Effect, Layer, Option, Schema } from "effect"
|
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"
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const ConsoleStateResponse = Schema.Struct({
|
const ConsoleStateResponse = Schema.Struct({
|
||||||
@@ -28,13 +32,30 @@ const ConsoleOrgList = Schema.Struct({
|
|||||||
orgs: Schema.Array(ConsoleOrgOption),
|
orgs: Schema.Array(ConsoleOrgOption),
|
||||||
}).annotate({ identifier: "ConsoleOrgList" })
|
}).annotate({ identifier: "ConsoleOrgList" })
|
||||||
|
|
||||||
|
const ConsoleSwitchPayload = Schema.Struct({
|
||||||
|
accountID: AccountID,
|
||||||
|
orgID: OrgID,
|
||||||
|
}).annotate({ identifier: "ConsoleSwitchInput" })
|
||||||
|
|
||||||
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
|
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" })
|
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
|
||||||
|
|
||||||
export const ExperimentalPaths = {
|
export const ExperimentalPaths = {
|
||||||
console: "/experimental/console",
|
console: "/experimental/console",
|
||||||
consoleOrgs: "/experimental/console/orgs",
|
consoleOrgs: "/experimental/console/orgs",
|
||||||
|
consoleSwitch: "/experimental/console/switch",
|
||||||
|
tool: "/experimental/tool",
|
||||||
toolIDs: "/experimental/tool/ids",
|
toolIDs: "/experimental/tool/ids",
|
||||||
worktree: "/experimental/worktree",
|
worktree: "/experimental/worktree",
|
||||||
worktreeReset: "/experimental/worktree/reset",
|
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.",
|
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, {
|
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||||
success: ToolIDs,
|
success: ToolIDs,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -141,6 +183,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
export const experimentalHandlers = Layer.unwrap(
|
export const experimentalHandlers = Layer.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const account = yield* Account.Service
|
const account = yield* Account.Service
|
||||||
|
const agents = yield* Agent.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
const project = yield* Project.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* () {
|
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
|
||||||
return yield* registry.ids()
|
return yield* registry.ids()
|
||||||
})
|
})
|
||||||
@@ -222,6 +287,8 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
handlers
|
handlers
|
||||||
.handle("console", getConsole)
|
.handle("console", getConsole)
|
||||||
.handle("consoleOrgs", listConsoleOrgs)
|
.handle("consoleOrgs", listConsoleOrgs)
|
||||||
|
.handle("consoleSwitch", switchConsole)
|
||||||
|
.handle("tool", tool)
|
||||||
.handle("toolIDs", toolIDs)
|
.handle("toolIDs", toolIDs)
|
||||||
.handle("worktree", worktree)
|
.handle("worktree", worktree)
|
||||||
.handle("worktreeCreate", worktreeCreate)
|
.handle("worktreeCreate", worktreeCreate)
|
||||||
@@ -232,6 +299,7 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(MCP.defaultLayer),
|
Layer.provide(MCP.defaultLayer),
|
||||||
Layer.provide(Project.defaultLayer),
|
Layer.provide(Project.defaultLayer),
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
app.get("/config/providers", (c) => handler(c.req.raw, context))
|
app.get("/config/providers", (c) => handler(c.req.raw, context))
|
||||||
app.get(ExperimentalPaths.console, (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.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.toolIDs, (c) => handler(c.req.raw, context))
|
||||||
app.get(ExperimentalPaths.worktree, (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))
|
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ async function defaultModel() {
|
|||||||
return run((provider) => provider.defaultModel())
|
return run((provider) => provider.defaultModel())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markPluginDependenciesReady(dir: string) {
|
||||||
|
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "package-lock.json"),
|
||||||
|
JSON.stringify({ packages: { "": { dependencies: { "@opencode-ai/plugin": "0.0.0" } } } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
||||||
const item = providers[ProviderID.make("opencode")]
|
const item = providers[ProviderID.make("opencode")]
|
||||||
expect(item).toBeDefined()
|
expect(item).toBeDefined()
|
||||||
@@ -2439,8 +2447,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
|
|||||||
test("plugin config providers persist after instance dispose", async () => {
|
test("plugin config providers persist after instance dispose", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const root = path.join(dir, ".opencode", "plugin")
|
const configDir = path.join(dir, ".opencode")
|
||||||
|
const root = path.join(configDir, "plugin")
|
||||||
await mkdir(root, { recursive: true })
|
await mkdir(root, { recursive: true })
|
||||||
|
await markPluginDependenciesReady(configDir)
|
||||||
|
await markPluginDependenciesReady(Global.Path.config)
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(root, "demo-provider.ts"),
|
path.join(root, "demo-provider.ts"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { GlobalBus } from "@/bus/global"
|
|||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||||
|
import { Database } from "../../src/storage"
|
||||||
import { Log } from "../../src/util"
|
import { Log } from "../../src/util"
|
||||||
import { Worktree } from "../../src/worktree"
|
import { Worktree } from "../../src/worktree"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
@@ -14,6 +15,7 @@ void Log.init({ print: false })
|
|||||||
|
|
||||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||||
|
const testWorktreeMutations = process.platform === "win32" ? test.skip : test
|
||||||
|
|
||||||
function app() {
|
function app() {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||||
@@ -61,9 +63,10 @@ describe("experimental HttpApi", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headers = { "x-opencode-directory": tmp.path }
|
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.console, { headers }),
|
||||||
app().request(ExperimentalPaths.consoleOrgs, { headers }),
|
app().request(ExperimentalPaths.consoleOrgs, { headers }),
|
||||||
|
app().request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, { headers }),
|
||||||
app().request(ExperimentalPaths.toolIDs, { headers }),
|
app().request(ExperimentalPaths.toolIDs, { headers }),
|
||||||
app().request(ExperimentalPaths.worktree, { headers }),
|
app().request(ExperimentalPaths.worktree, { headers }),
|
||||||
app().request(ExperimentalPaths.resource, { headers }),
|
app().request(ExperimentalPaths.resource, { headers }),
|
||||||
@@ -78,6 +81,15 @@ describe("experimental HttpApi", () => {
|
|||||||
expect(consoleOrgs.status).toBe(200)
|
expect(consoleOrgs.status).toBe(200)
|
||||||
expect(await consoleOrgs.json()).toEqual({ orgs: [] })
|
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(toolIDs.status).toBe(200)
|
||||||
expect(await toolIDs.json()).toContain("bash")
|
expect(await toolIDs.json()).toContain("bash")
|
||||||
|
|
||||||
@@ -88,7 +100,26 @@ describe("experimental HttpApi", () => {
|
|||||||
expect(await resources.json()).toEqual({})
|
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 } })
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
|
||||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||||
|
|||||||
Reference in New Issue
Block a user