Compare commits

..
5 Commits
43 changed files with 849 additions and 997 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve reference insertion order when later config documents override an existing reference.
+114 -167
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,47 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
@@ -107,19 +66,17 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(cause)
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(cause)
},
),
]),
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
),
),
)
@@ -148,15 +105,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
),
),
)
@@ -176,15 +132,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
),
),
)
@@ -200,7 +155,7 @@ describe("RequestExecutor", () => {
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([]))),
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
)
it.effect("reports the request sent by middleware", () =>
@@ -249,11 +204,9 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
),
),
)
@@ -269,7 +222,7 @@ describe("RequestExecutor", () => {
classification: "payload-too-large",
})
expect(error.reason.http?.status).toBe(413)
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
@@ -285,11 +238,9 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(413)
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
),
),
)
@@ -303,7 +254,7 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -317,15 +268,13 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toContain(largeProviderMessage)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
),
),
)
@@ -340,7 +289,7 @@ describe("RequestExecutor", () => {
_tag: "InvalidRequest",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -352,7 +301,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -369,7 +318,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -399,12 +348,10 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("rate limited")
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
),
),
)
@@ -417,7 +364,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -437,20 +384,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
),
),
)
@@ -470,20 +415,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
),
),
)
@@ -496,10 +439,14 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
),
)
@@ -522,12 +469,10 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(status)
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
),
)
@@ -538,20 +483,28 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("preserves response body fields", () =>
@@ -563,11 +516,9 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
),
),
)
@@ -581,9 +532,7 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
),
),
)
@@ -591,9 +540,7 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -624,7 +571,7 @@ describe("RequestExecutor", () => {
})
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
+5 -5
View File
@@ -1,11 +1,11 @@
export * as ConfigMCPPlugin from "./mcp.js"
export * as ConfigMcpPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import type { ServerConfig } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { MCP } from "../../mcp/index.js"
import { Mcp } from "../../mcp/index.js"
export const Plugin = define({
id: "opencode.config.mcp",
@@ -18,7 +18,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
events: Stream.Stream<{ readonly type: string }, unknown>,
) {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const loaded = { entries: [] as Entry[] }
yield* events.pipe(
@@ -43,7 +43,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, Mcp.ServerConfig>()
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
+3 -1
View File
@@ -20,13 +20,14 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
draft.add(
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
@@ -47,6 +48,7 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
+2 -2
View File
@@ -23,7 +23,7 @@ import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
@@ -84,7 +84,7 @@ const locationServiceNodes = [
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
Mcp.node,
Permission.node,
Tool.node,
ToolOutput.node,
+3 -3
View File
@@ -1,4 +1,4 @@
export * as MCPClient from "./client.js"
export * as McpClient from "./client.js"
import path from "node:path"
import { pathToFileURL } from "node:url"
@@ -29,7 +29,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
@@ -222,7 +222,7 @@ export const connect = Effect.fnUntraced(function* (
const exit = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server,
command,
args,
+30 -30
View File
@@ -1,4 +1,4 @@
export * as MCP from "./index.js"
export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
@@ -16,7 +16,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
import { Location } from "../location.js"
import { waitForAbort } from "@opencode-ai/util/process"
import { State } from "../state.js"
import type { MCPClient } from "./client.js"
import type { McpClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
@@ -114,7 +114,7 @@ type ServerEntry = {
status: Status
readonly startup: Latch.Latch
scope?: Scope.Closeable
client?: MCPClient.Connection
client?: McpClient.Connection
tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
@@ -235,8 +235,8 @@ export const layer = (options?: Options) =>
method: { id: methodID, type: "oauth", label: name },
authorize: () =>
Effect.gen(function* () {
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* McpOAuth.authorize({ name, config: remote, methodID })
}),
})
})
@@ -254,7 +254,7 @@ export const layer = (options?: Options) =>
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const base = {
@@ -270,7 +270,7 @@ export const layer = (options?: Options) =>
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
const integrationID = entry.integrationID
@@ -282,7 +282,7 @@ export const layer = (options?: Options) =>
const match = stored.find((credential) => credential.id === credentialID)
return match && match.value.type === "oauth" ? match.value : undefined
}
return MCPOAuth.provider({
return McpOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
@@ -303,22 +303,22 @@ export const layer = (options?: Options) =>
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return MCPOAuth.toTokens(oauth)
return McpOAuth.toTokens(oauth)
},
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
const value = MCPOAuth.toCredential({
const value = McpOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
},
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
@@ -330,7 +330,7 @@ export const layer = (options?: Options) =>
const elicitation = {
create: (input: {
readonly server: string
readonly params: MCPClient.ElicitationParams
readonly params: McpClient.ElicitationParams
readonly signal: AbortSignal
}) =>
Effect.gen(function* () {
@@ -355,7 +355,7 @@ export const layer = (options?: Options) =>
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.map(
(state): MCPClient.ElicitationResult => ({
(state): McpClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
}),
),
@@ -375,13 +375,13 @@ export const layer = (options?: Options) =>
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.map((state): MCPClient.ElicitationResult => {
Effect.map((state): McpClient.ElicitationResult => {
if (state.status !== "answered") return { action: "cancel" }
return {
action: "accept",
content: Object.fromEntries(
Object.entries(state.answer).map(
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
typeof value === "object" ? [key, Array.from(value)] : [key, value],
),
),
@@ -395,9 +395,9 @@ export const layer = (options?: Options) =>
if (!formID) return
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
} satisfies McpClient.ElicitationHandler
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
new Tool({
server,
name: def.name,
@@ -407,7 +407,7 @@ export const layer = (options?: Options) =>
outputSchema: def.outputSchema,
})
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
@@ -422,7 +422,7 @@ export const layer = (options?: Options) =>
),
})
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
@@ -431,7 +431,7 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
@@ -440,14 +440,14 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, entry, def))
}),
)
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.prompts().pipe(
Effect.orElseSucceed(() => []),
Effect.map((defs) => {
@@ -459,7 +459,7 @@ export const layer = (options?: Options) =>
// Runs a connection callback under the server lock, dropping it if the connection is no longer
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
const whenLive =
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
<E>(effect: Effect.Effect<void, E>) =>
fork(
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
@@ -468,7 +468,7 @@ export const layer = (options?: Options) =>
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
live(
@@ -491,7 +491,7 @@ export const layer = (options?: Options) =>
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
}
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
const fields = { server, logger: message.logger, level: message.level, data: message.data }
switch (message.level) {
case "debug":
@@ -518,10 +518,10 @@ export const layer = (options?: Options) =>
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(
const result = yield* McpClient.connect(
name,
entry.config,
location.directory,
@@ -555,7 +555,7 @@ export const layer = (options?: Options) =>
entry.scope = undefined
const error = Cause.squash(result.cause)
entry.status =
error instanceof MCPClient.NeedsAuthError
error instanceof McpClient.NeedsAuthError
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
@@ -938,4 +938,4 @@ function toElicitationField(key: string, property: ElicitationProperty, required
}
}
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
+3 -3
View File
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { McpTool } from "../tool/mcp.js"
import { MCP } from "./index.js"
import { Mcp } from "./index.js"
import { Instructions } from "../instructions/index.js"
const Summary = Schema.Struct({
@@ -63,7 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return Service.of({
load: Effect.fn("McpInstructions.load")(function* (selection) {
@@ -110,4 +110,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPOAuth from "./oauth.js"
export * as McpOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPStdio from "./stdio.js"
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
+2 -2
View File
@@ -13,7 +13,7 @@ import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
@@ -197,7 +197,7 @@ export const node = makeLocationNode({
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
Location.node,
Reference.node,
Skill.node,
+11 -13
View File
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,20 +12,18 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
const loaded = { prompts: [] as Mcp.Prompt[] }
yield* bus.subscribe(Mcp.PromptsChanged).pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
Effect.forkScoped({ startImmediately: true }),
)
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
+4 -4
View File
@@ -3,7 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -16,7 +16,7 @@ import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
@@ -41,7 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const skill = yield* Skill.Service
@@ -295,7 +295,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
+8 -8
View File
@@ -18,7 +18,7 @@ import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
@@ -44,7 +44,7 @@ import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
@@ -78,7 +78,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -114,7 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -158,7 +158,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Mcp.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -209,7 +209,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Mcp.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -234,8 +234,8 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
VcsGitPlugin.Plugin,
AgentPlugin.Plugin,
@@ -1,4 +1,4 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
export * as McpCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
+3 -3
View File
@@ -6,7 +6,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Session } from "../session.js"
export interface Interface {
@@ -38,7 +38,7 @@ export interface Interface {
readonly mcp: {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Mcp.ServerInfo[] }, unknown>
}
}
}
@@ -130,7 +130,7 @@ export const providerLayerWithCell = (cell: Cell) =>
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
+4 -4
View File
@@ -6,7 +6,7 @@ import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } fro
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Permission } from "../permission.js"
import { Tool } from "../tool.js"
@@ -26,12 +26,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const lock = Semaphore.makeUnsafe(1)
let discovered: MCP.Tool[] = []
let discovered: Mcp.Tool[] = []
// Register once after initial discovery; only subsequent updates need a debounced reload.
const initial = yield* lock
@@ -134,5 +134,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
deps: [Tool.node, Mcp.node, Bus.node, Permission.node],
})
+3 -5
View File
@@ -19,7 +19,7 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@@ -43,7 +43,7 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
[MCP.node, emptyMcpLayer],
[Mcp.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
@@ -431,9 +431,7 @@ function sourceCases() {
{
name: "updated",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first"))),
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
-43
View File
@@ -34,41 +34,6 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* references.transform((draft) =>
draft.add(
"external",
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
),
)
yield* ConfigReferencePlugin.Plugin.effect(host)
const result = yield* references.list()
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
AbsolutePath.make(path.resolve("/config/second/shared")),
)
}).pipe(
Effect.provide(
Config.testLayer([
referenceConfig("/config/first/opencode.json", {
shared: "./shared",
first: "./first",
}),
referenceConfig("/config/second/opencode.json", {
shared: "./shared",
second: "./second",
}),
]),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -137,14 +102,6 @@ function config(name: string) {
})
}
function referenceConfig(file: string, references: Record<string, string>) {
return new Document({
type: "document",
path: AbsolutePath.make(file),
info: decode({ references }),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
+4 -4
View File
@@ -1,13 +1,13 @@
import { Effect, Layer, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./location"
export const emptyMcpLayer = Layer.succeed(
MCP.Service,
MCP.Service.of({
Mcp.Service,
Mcp.Service.of({
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
servers: () => Effect.succeed([]),
@@ -20,7 +20,7 @@ export const emptyMcpLayer = Layer.succeed(
instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]),
prompt: () => Effect.undefined,
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.undefined,
}),
)
+44
View File
@@ -0,0 +1,44 @@
export * as TestWebSearch from "./websearch"
import { Context, Deferred, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
export interface Interface extends WebSearch.Interface {
readonly queries: readonly WebSearch.Input[]
/** Waits for query arrivals, not provider execution or query completion. */
readonly wait: (count: number) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
// No providers are installed: tests register local executors through transform.
// The normal Bus and KV implementations use the default in-memory database.
export const layer = Layer.effectContext(
Effect.gen(function* () {
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const websearch = Context.get(context, WebSearch.Service)
const queries: WebSearch.Input[] = []
let started = yield* Deferred.make<void>()
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
queries.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(() => wait(count))),
)
const test = Service.of({
...websearch,
queries,
wait,
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
queries.push({ ...input })
const previous = started
started = yield* Deferred.make<void>()
yield* Deferred.succeed(previous, undefined)
return yield* websearch.query(input)
}),
})
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
}),
)
+2 -2
View File
@@ -31,7 +31,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -998,7 +998,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
yield* supervisor.flush
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
+8 -8
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { Permission } from "@opencode-ai/core/permission"
import { McpTool } from "@opencode-ai/core/tool/mcp"
@@ -17,15 +17,15 @@ const selection = (permissions: Permission.Ruleset = []) => {
}
const instructions = (server: string, text: string) =>
new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
new Mcp.ServerInstructions({ server: Mcp.ServerName.make(server), instructions: text })
const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.ServerName.make(server), name })
const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
AppNodeBuilder.build(McpInstructions.node, [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
instructions: () => Effect.succeed(catalog()),
tools: () => Effect.succeed(tools()),
}),
@@ -113,7 +113,7 @@ describe("McpInstructions", () => {
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
() => [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })],
),
),
),
@@ -125,7 +125,7 @@ describe("McpInstructions", () => {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
tools = [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })]
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
expect(changed.text).toBe(
[
+2 -2
View File
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test"
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Integration } from "@opencode-ai/core/integration"
import { MCPOAuth } from "@opencode-ai/core/mcp/oauth"
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
import { Effect } from "effect"
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
@@ -12,7 +12,7 @@ const authorize = (redirect_uri?: string) =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* MCPOAuth.authorize({
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
+61 -61
View File
@@ -16,7 +16,7 @@ import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Config } from "@opencode-ai/core/config"
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { ConfigMcpPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -27,9 +27,9 @@ import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpClient } from "@opencode-ai/core/mcp/client"
import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -180,7 +180,7 @@ function resourceServer(
function resourceMcpLayer(
server: string | typeof ConfigMCP.Server.Type,
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
options?: MCP.Options,
options?: Mcp.Options,
overrides?: {
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
@@ -193,10 +193,10 @@ function resourceMcpLayer(
return Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* ConfigMCPPlugin.register(bus.subscribe())
yield* ConfigMcpPlugin.register(bus.subscribe())
}),
).pipe(
Layer.provideMerge(MCP.layer(options)),
Layer.provideMerge(Mcp.layer(options)),
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
@@ -267,13 +267,13 @@ function resourceMcpLayer(
}
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
const mcp = Layer.mock(MCP.Service, {
const mcp = Layer.mock(Mcp.Service, {
tools: () =>
Effect.succeed([
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "search",
description: "Search",
inputSchema: { type: "object", properties: {} },
@@ -283,28 +283,28 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
codemode: false,
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "fail",
codemode: false,
description: "Always fails",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "media",
codemode: false,
description: "Returns text and an image",
@@ -315,15 +315,15 @@ const mcp = Layer.mock(MCP.Service, {
Effect.sync(() => {
calls += 1
if (input.name === "fail")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: true,
content: [{ type: "text", text: "search index unavailable" }],
})
if (input.name === "media")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [
@@ -332,14 +332,14 @@ const mcp = Layer.mock(MCP.Service, {
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
structured: { ok: true },
@@ -358,7 +358,7 @@ const permissions = Layer.mock(Permission.Service, {
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[MCP.node, mcp],
[Mcp.node, mcp],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
@@ -367,12 +367,12 @@ const it = testEffect(
describe("MCP errors", () => {
test("expose useful messages", () => {
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(new Mcp.NotFoundError({ server: Mcp.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
new Mcp.ToolCallError({ server: Mcp.ServerName.make("demo"), tool: "search", message: "failed" }).message,
).toBe("failed")
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
expect(new McpClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new McpClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
})
})
@@ -488,7 +488,7 @@ test("spawns local MCP servers through the location environment", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
const connection = yield* McpClient.connect("environment", config, import.meta.dir)
yield* connection.tools()
}),
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
@@ -513,7 +513,7 @@ test("reports a local MCP server as failed when the location has no execution pl
await Effect.runPromise(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.tools()
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
expect(status).toEqual({
@@ -528,7 +528,7 @@ test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "not-started",
command: process.execPath,
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
@@ -551,7 +551,7 @@ test("joins concurrent stdio transport closes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "concurrent-close",
command: "unused",
args: [],
@@ -605,7 +605,7 @@ test("closes a stdio process that finishes spawning after close", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "close-during-spawn",
command: "unused",
args: [],
@@ -816,7 +816,7 @@ for (const entry of [
})
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
expect(error).toBeInstanceOf(MCPClient.ConnectError)
expect(error).toBeInstanceOf(McpClient.ConnectError)
expect(server.state.initializations).toBe(entry.attempts)
expect(server.state.urls).toHaveLength(entry.attempts)
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
@@ -956,7 +956,7 @@ test("accepts empty MCP elicitations without creating forms", async () => {
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const forms = yield* Form.Service
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
expect(yield* forms.list()).toEqual([])
@@ -976,7 +976,7 @@ test("acknowledges completed MCP URL elicitations without returning internal con
const server = yield* resourceServer({ resources: false, urlElicitation: true })
const created = yield* Deferred.make<Form.Info>()
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const forms = yield* Form.Service
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
@@ -1006,7 +1006,7 @@ test("loads and reads MCP resources", async () => {
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect(yield* service.resourceCatalog()).toEqual({
resources: [
{
@@ -1053,12 +1053,12 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
Effect.scoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(published).toContain(McpEvent.StatusChanged.type)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
yield* service.add(
"dynamic",
new ConfigMCP.Local({
@@ -1101,7 +1101,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
yield* service.remove("dynamic")
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
expect(yield* service.tools()).toEqual([])
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
}).pipe(
Effect.provide(
resourceMcpLayer(
@@ -1125,7 +1125,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
"manages live MCP servers entirely through scoped transforms",
() =>
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* Effect.scoped(
Effect.gen(function* () {
@@ -1168,7 +1168,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
}),
)
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
expect(yield* service.tools()).toEqual([])
}),
)
@@ -1177,7 +1177,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
@@ -1220,7 +1220,7 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
yield* service.transform((draft) =>
draft.update("resources", (server) => {
@@ -1263,7 +1263,7 @@ test("reconciles only changed MCP server config", async () => {
} satisfies Payload<typeof Event.Updated>)
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.tools()
expect(server.state.toolLists).toBe(1)
expect(server.state.initializations).toBe(1)
@@ -1330,7 +1330,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
Effect.scoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
// Whatever order the racing operations land in, the resulting state must be consistent.
yield* Effect.all(
@@ -1373,8 +1373,8 @@ test("serializes concurrent MCP lifecycle operations", async () => {
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string, description = name) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
new Mcp.Tool({
server: Mcp.ServerName.make(server),
name,
description,
codemode: false,
@@ -1498,13 +1498,13 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
Effect.succeed(
new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
@@ -1541,12 +1541,12 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
tools: () =>
Effect.sync(() => [
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: `read_${++reads}`,
codemode: false,
inputSchema: { type: "object", properties: {} },
+3 -3
View File
@@ -4,7 +4,7 @@ import { Command } from "@opencode-ai/core/command"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/schema/session"
@@ -24,8 +24,8 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
[MCP.node, emptyMcpLayer],
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
[Mcp.node, emptyMcpLayer],
[Location.node, locationLayer],
]),
)
+3 -3
View File
@@ -14,7 +14,7 @@ import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -72,7 +72,7 @@ export const PluginTestLayer = LayerNode.compile(
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
PluginRuntime.node,
Permission.node,
PluginHooks.node,
@@ -89,7 +89,7 @@ export const PluginTestLayer = LayerNode.compile(
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Config.testLayer()],
[MCP.node, emptyMcpLayer],
[Mcp.node, emptyMcpLayer],
[Generate.node, generateLayer],
[Permission.node, permissionLayer],
],
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { MCPCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
import { McpCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
import type { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, type Types } from "effect"
import { it } from "../lib/effect"
@@ -51,7 +51,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
)
const base = host()
yield* MCPCodeModeExclusionPlugin.Plugin.effect(
yield* McpCodeModeExclusionPlugin.Plugin.effect(
host({
mcp: {
...base.mcp,
+134 -194
View File
@@ -1,13 +1,12 @@
import { beforeEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
import { Config } from "@opencode-ai/core/config"
import { KV } from "@opencode-ai/core/kv"
import { Form } from "@opencode-ai/core/form"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Document, Info } from "@opencode-ai/schema/config"
import { Session } from "@opencode-ai/core/session"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { Tool } from "@opencode-ai/core/tool"
@@ -19,7 +18,7 @@ import { imagePassthrough } from "./lib/image"
import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { webSearchHost } from "./plugin/host"
import { produce } from "immer"
import { TestWebSearch } from "./lib/websearch"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
@@ -33,142 +32,78 @@ const webSearchToolNode = makeLocationNode({
})
const sessionID = Session.ID.make("ses_websearch_test")
const assertions: Permission.AssertInput[] = []
const queries: WebSearch.Input[] = []
const formRequests: Form.CreateInput[] = []
let selection: WebSearch.ID | "random" | false | undefined
const providers = [
{ id: WebSearch.ID.make("exa"), name: "Exa" },
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
]
let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let queryError: WebSearch.Error | undefined
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
beforeEach(() => {
assertions.length = 0
queries.length = 0
formRequests.length = 0
selection = undefined
providerRequired = false
formResponse = { status: "cancelled" }
formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
queryError = undefined
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
})
class Fixture {
assertions: Permission.AssertInput[] = []
events: string[] = []
formRequests: Form.CreateInput[] = []
formResponse: Form.TerminalState = { status: "cancelled" }
formResponses: Form.TerminalState[] = []
formWait = Effect.void
error: HttpClientError.HttpClientError | undefined
results: readonly WebSearch.Result[] = [
{ url: "https://example.com", title: "Search results", content: "search results", time: {} },
]
}
const permission = permissionLayer({
assert: (input) => Effect.sync(() => assertions.push(input)),
})
const websearch = Layer.succeed(
WebSearch.Service,
WebSearch.Service.of({
transform: (transform) =>
Effect.sync(() => {
transform({
add: () => undefined,
default: {
get: () => selection,
set: (next) => (selection = next),
},
})
return { dispose: Effect.void }
}),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers),
default: () =>
Effect.gen(function* () {
if (selection === false) return yield* new WebSearch.DisabledError()
return selection ? providers.find((provider) => provider.id === selection) : undefined
}),
select: (next) => Effect.sync(() => (selection = next)),
query: (input) =>
Effect.gen(function* () {
queries.push(input)
if (queryBarrier && synchronizedQueries < 5) {
synchronizedQueries++
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
yield* Deferred.await(queryBarrier)
}
if (queryError) return yield* queryError
if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError()
if (selection)
return new WebSearch.Response({
providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection),
results: result.results,
})
return result
}),
}),
)
const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: (input) =>
Effect.sync(() => {
formRequests.push(input)
return formResponses.shift() ?? formResponse
}),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Document({
type: "document",
info: new Info({
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
const it = testEffect(TestWebSearch.layer)
const setup = Effect.gen(function* () {
const fixture = new Fixture()
const websearch = yield* TestWebSearch.Service
const kv = yield* KV.Service
yield* websearch.transform((draft) =>
providers.forEach((provider) =>
draft.add({
...provider,
execute: () =>
Effect.gen(function* () {
fixture.events.push("query")
if (fixture.error) return yield* fixture.error
return fixture.results
}),
}),
),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [
[
Permission.node,
permissionLayer({
assert: (input) =>
Effect.sync(() => {
fixture.events.push("permission")
fixture.assertions.push(input)
}),
}),
]),
update: (update) =>
Effect.sync(() => {
const info = produce(
new Info({
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
}),
update,
)
selection = info.websearch === false ? false : info.websearch?.provider
return info
}),
changes: () => Stream.never,
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
[Permission.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[Config.node, config],
[Image.node, imagePassthrough],
]),
)
],
[WebSearch.node, Layer.succeed(WebSearch.Service, websearch)],
[
Form.node,
Layer.mock(Form.Service, {
ask: (input) =>
Effect.gen(function* () {
fixture.formRequests.push(input)
yield* fixture.formWait
return fixture.formResponses.shift() ?? fixture.formResponse
}),
}),
],
[Image.node, imagePassthrough],
]),
)
return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })
})
describe("WebSearchTool registration", () => {
it.effect("asserts permission before delegating to WebSearch", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const fixture = yield* setup
const registry = fixture.registry
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
expect(
@@ -186,7 +121,7 @@ describe("WebSearchTool registration", () => {
status: "completed",
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
})
expect(assertions).toMatchObject([
expect(fixture.assertions).toMatchObject([
{
sessionID,
action: "websearch",
@@ -195,28 +130,29 @@ describe("WebSearchTool registration", () => {
metadata: { query: "effect typescript" },
},
])
expect(queries).toEqual([
expect(fixture.websearch.queries).toEqual([
{
query: "effect typescript",
providerID: WebSearch.ID.make("exa"),
},
])
expect(fixture.events).toEqual(["permission", "query"])
}),
)
it.effect("keeps normalized results in structured output", () =>
Effect.gen(function* () {
result = new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
})
const registry = yield* Tool.Service
const fixture = yield* setup
yield* fixture.websearch.select(WebSearch.ID.make("parallel"))
fixture.results = [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
]
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -250,8 +186,10 @@ describe("WebSearchTool registration", () => {
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
const registry = yield* Tool.Service
const fixture = yield* setup
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
fixture.results = []
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -268,20 +206,21 @@ describe("WebSearchTool registration", () => {
it.effect("asks once and uses the default provider when web search is first enabled", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = yield* Tool.Service
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = fixture.registry
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(selection).toBe("random")
expect(queries).toHaveLength(2)
expect(formRequests).toEqual([
const first = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
})
expect(first.status).toBe("completed")
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.formRequests).toEqual([
{
sessionID,
title: "Web Search",
@@ -309,26 +248,27 @@ describe("WebSearchTool registration", () => {
},
])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(formRequests).toHaveLength(1)
expect(queries).toHaveLength(3)
const second = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
})
expect(second.status).toBe("completed")
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
expect(fixture.formRequests).toHaveLength(1)
expect(fixture.websearch.queries).toHaveLength(3)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
providerRequired = true
formResponses.push(
const fixture = yield* setup
fixture.formResponses.push(
{ status: "answered", answer: { choice: "choose" } },
{ status: "answered", answer: { provider: "parallel" } },
)
const registry = yield* Tool.Service
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -337,9 +277,10 @@ describe("WebSearchTool registration", () => {
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
expect(selection).toBe(WebSearch.ID.make("parallel"))
expect(queries).toHaveLength(2)
expect(formRequests[1]).toEqual({
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(WebSearch.ID.make("parallel"))
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.websearch.queries[1]?.providerID).toBe(WebSearch.ID.make("parallel"))
expect(fixture.formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
@@ -362,10 +303,10 @@ describe("WebSearchTool registration", () => {
it.effect("shares provider consent across concurrent searches", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
queryBarrier = yield* Deferred.make<void>()
const registry = yield* Tool.Service
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
fixture.formWait = fixture.websearch.wait(5)
const registry = fixture.registry
const results = yield* Effect.all(
Array.from({ length: 5 }, (_, index) =>
@@ -384,16 +325,16 @@ describe("WebSearchTool registration", () => {
)
expect(results.every((item) => item.status === "completed")).toBe(true)
expect(formRequests).toHaveLength(1)
expect(selection).toBe("random")
expect(fixture.formRequests).toHaveLength(1)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = yield* Tool.Service
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -402,16 +343,18 @@ describe("WebSearchTool registration", () => {
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "error" })
expect(selection).toBe(false)
expect(queries).toHaveLength(1)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(false)
expect(yield* fixture.websearch.default().pipe(Effect.flip)).toBeInstanceOf(WebSearch.DisabledError)
expect(fixture.websearch.queries).toHaveLength(1)
}),
)
it.effect("reports safe HTTP failures with the attempted provider", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const fixture = yield* setup
const registry = fixture.registry
const tools = yield* registry.snapshot()
selection = WebSearch.ID.make("exa")
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
yield* Effect.forEach(
[
@@ -422,14 +365,11 @@ describe("WebSearchTool registration", () => {
({ status, message }, index) =>
Effect.gen(function* () {
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
queryError = new WebSearch.RequestError({
providerID: WebSearch.ID.make("exa"),
cause: new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
fixture.error = new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
})
const progress: Tool.Metadata[] = []
+39 -5
View File
@@ -1,13 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Effect, Exit, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
import { TestWebSearch } from "./lib/websearch"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const it = testEffect(TestWebSearch.layer)
const register = (id: string) =>
Effect.gen(function* () {
@@ -36,11 +35,28 @@ const register = (id: string) =>
})
describe("WebSearch", () => {
it.effect("shares the normal and test interfaces without installing live providers", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const test = yield* TestWebSearch.Service
expect(websearch).toBe(test)
expect(yield* websearch.providers()).toEqual([])
expect(test.queries).toEqual([])
expect((yield* websearch.query({ query: "unconfigured" }).pipe(Effect.flip))._tag).toBe(
"WebSearch.ProviderRequired",
)
yield* test.wait(1)
expect(test.queries).toEqual([{ query: "unconfigured" }])
}),
)
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const test = yield* TestWebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
new WebSearch.Response({
@@ -57,6 +73,7 @@ describe("WebSearch", () => {
)
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
expect(parallel.calls).toEqual([{ query: "effect" }])
expect(test.queries).toEqual([{ query: "effect", providerID: parallel.providerID }, { query: "default" }])
}),
)
@@ -81,6 +98,23 @@ describe("WebSearch", () => {
}),
)
it.effect("reloads active transforms from their current source", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const source = { providerID: exa.providerID }
yield* websearch.transform((draft) => draft.default.set(source.providerID))
expect((yield* websearch.default())?.id).toBe(exa.providerID)
source.providerID = parallel.providerID
const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* websearch.default())?.id).toBe(parallel.providerID)
}),
)
it.effect("persists the selected provider in KV", () =>
Effect.gen(function* () {
const parallel = yield* register("parallel")
+20 -18
View File
@@ -7,22 +7,24 @@ type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
const make =
<R>(testLayer: Layer.Layer<R>) =>
<A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(testLayer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
export const it = { effect }
export const it = { effect: make(layer), live: make(TestConsole.layer) }
+134 -180
View File
@@ -20,6 +20,7 @@ import {
emitPromise,
generate,
GenerationError,
type Output,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
@@ -32,6 +33,21 @@ function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(sour
return emitEffect(compileContract(source))
}
async function emittedModule(output: Output) {
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const dispose = () => rm(directory, { recursive: true, force: true })
try {
// Finish each write before cleanup can run, even when a later write fails.
await Array.fromAsync(output.files, (file) => Bun.write(join(directory, file.path), file.content))
const module = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
return { module, [Symbol.asyncDispose]: dispose }
} catch (cause) {
await dispose()
throw cause
}
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
@@ -352,27 +368,21 @@ describe("HttpApiCodegen.generate", () => {
),
)
const output = emitPromise(compileContract(source))
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const methods: Array<string> = []
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
@@ -825,26 +835,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
})
test("maps an emitted no-content response to undefined", async () => {
@@ -858,20 +861,13 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
})
test("executes an emitted binary wildcard GET through fetch", async () => {
@@ -885,28 +881,21 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
} finally {
await rm(directory, { recursive: true, force: true })
}
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
@@ -923,29 +912,22 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" })).toBe(
"admitted",
)
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
})
test("serializes an opaque union payload as the direct JSON body", async () => {
@@ -962,26 +944,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
})
test("serializes explicit null query values", async () => {
@@ -995,26 +970,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
await client.session.list({ parentID: null })
await client.session.list({ parentID: null })
expect(request?.url).toBe("https://example.com/session?parentID=null")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(request?.url).toBe("https://example.com/session?parentID=null")
})
test("rejects with declared tagged errors and exports a type guard", async () => {
@@ -1029,22 +997,15 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(emitted.module.isMissing(error)).toBeTrue()
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
@@ -1060,42 +1021,35 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let requests = 0
let url: string | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
})
test("preserves public group and endpoint identifiers exactly", () => {
@@ -1138,7 +1092,7 @@ describe("HttpApiCodegen.generate", () => {
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.effect("keeps the strict generated-consumer fixture current", () =>
it.live("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
+8 -8
View File
@@ -1,11 +1,11 @@
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
const notFound = <A, R>(effect: Effect.Effect<A, MCP.NotFoundError, R>) =>
const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
@@ -14,7 +14,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.list",
Effect.fn(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
return yield* response(
service
.servers()
@@ -29,7 +29,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.add",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.add(ctx.params.server, ctx.payload.config)
return HttpApiSchema.NoContent.make()
}),
@@ -37,7 +37,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.remove",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* notFound(service.remove(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
@@ -45,7 +45,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.connect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* notFound(service.connect(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
@@ -53,7 +53,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.disconnect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* notFound(service.disconnect(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
@@ -61,7 +61,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.resource.catalog",
Effect.fn(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
return yield* response(service.resourceCatalog())
}),
)
+3 -3
View File
@@ -17,7 +17,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@@ -118,8 +118,8 @@ function makeRoutes<AuthError, AuthServices>(
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
Mcp.node,
Mcp.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
+48 -63
View File
@@ -3,75 +3,60 @@ import path from "node:path"
import { expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
import { AbsolutePath } from "@opencode-ai/schema/schema"
it.live("returns ordered config entries for the requested directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-config-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
})
const url = new URL("/api/config", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
const server = yield* startServer(global)
const url = new URL("/api/config", server.base)
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(document?.path).toBe(AbsolutePath.make(config))
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(document?.path).toBe(AbsolutePath.make(config))
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
+10 -10
View File
@@ -61,7 +61,7 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
const body: unknown = yield* Effect.promise(() => response.json())
if (typeof body !== "object" || body === null) throw new Error("Expected a health response object")
expect((body as Record<string, unknown>)["healthy"]).toBe(true)
}).pipe(Effect.scoped),
}),
)
it.live("activates credentials through the HttpApi", () =>
@@ -71,7 +71,7 @@ it.live("activates credentials through the HttpApi", () =>
handler(new Request("http://opencode.local/api/credential/cred_missing/activate", { method: "POST" })),
)
expect(response.status).toBe(204)
}).pipe(Effect.scoped),
}),
)
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
@@ -93,7 +93,7 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
),
)
expect(preflight.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
}).pipe(Effect.scoped),
}),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
@@ -113,7 +113,7 @@ it.live("cancels a stale OpenAI OAuth callback server before falling back", () =
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback")
}).pipe(Effect.scoped),
}),
)
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
@@ -133,7 +133,7 @@ it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback")
}).pipe(Effect.scoped),
}),
)
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
@@ -155,7 +155,7 @@ it.live("explains how to recover when both OpenAI OAuth callback ports are busy"
"OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.",
kind: "integration_authorization",
})
}).pipe(Effect.scoped),
}),
)
it.live("treats destroying a missing workspace as success", () =>
@@ -171,7 +171,7 @@ it.live("treats destroying a missing workspace as success", () =>
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ destroyed: false })
}).pipe(Effect.scoped),
}),
)
it.live("creates idempotent caller-identified workspaces through the HttpApi", () =>
@@ -213,7 +213,7 @@ it.live("creates idempotent caller-identified workspaces through the HttpApi", (
const minted = yield* create({ provider: "fake" })
expect(minted.status).toBe(200)
expect(yield* Effect.promise(() => minted.json())).toMatchObject({ data: expect.stringMatching(/^wrk_/) })
}).pipe(Effect.scoped),
}),
)
it.live("serves the session view operation and missing-session error", () =>
@@ -260,7 +260,7 @@ it.live("serves the session view operation and missing-session error", () =>
),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
@@ -283,5 +283,5 @@ it.live("stays serviceable when the first request aborts", () =>
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
expect(second.status).toBe(200)
}).pipe(Effect.scoped),
}),
)
+19
View File
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { ServerProcess } from "../../src/process"
export const startServer = Effect.fnUntraced(function* (directory: string) {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory },
fs: { filewatcher: false },
})
return {
base: HttpServer.formatAddress(server.address),
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}
})
+29 -33
View File
@@ -31,41 +31,37 @@ const generate = makeLocationNode({
})
it.live("uses base configuration without depending on process.cwd()", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-generate-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
]),
)
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
},
{ overrides: [[Generate.node, generate]] },
)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-generate-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
]),
)
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
},
{ overrides: [[Generate.node, generate]] },
)
expect(global).not.toBe(process.cwd())
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
model: { providerID: "base", model: "default" },
})
expect(global).not.toBe(process.cwd())
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
model: { providerID: "base", model: "default" },
})
const legacy = new URL("http://opencode.local/api/generate")
legacy.searchParams.set("location[directory]", project)
expect(yield* request(handler, legacy)).toEqual({
model: { providerID: "base", model: "default" },
})
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
const legacy = new URL("http://opencode.local/api/generate")
legacy.searchParams.set("location[directory]", project)
expect(yield* request(handler, legacy)).toEqual({
model: { providerID: "base", model: "default" },
})
}),
)
function request(handler: (request: Request) => Promise<Response>, url: URL) {
+28 -43
View File
@@ -2,54 +2,39 @@ import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
it.live("waits for plugin initialization before listing models", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
})
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* startServer(tmp.path)
const url = new URL("/api/model", server.base)
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -141,5 +141,5 @@ it.live("updates completed assistant message content through the session HTTP AP
_tag: "ConflictError",
resource: state.assistant,
})
}).pipe(Effect.scoped),
}),
)
+1 -1
View File
@@ -29,5 +29,5 @@ it.live("boots the workerd profile over durable object storage", () =>
const body: unknown = yield* Effect.promise(() => health.json())
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
}),
)
+43 -52
View File
@@ -3,67 +3,58 @@ import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
const location = new URL("/api/location", base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, base)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const location = new URL("/api/location", server.base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() =>
fetch(location, { headers: server.headers }).then((response) => response.json()),
)
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, server.base)
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
expect(removed.status).toBe(204)
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {