mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
5
Commits
reference-map
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d5f83ffd | ||
|
|
1455995ac7 | ||
|
|
52c04508a2 | ||
|
|
5a67fcc17e | ||
|
|
73b575468e |
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve reference insertion order when later config documents override an existing reference.
|
||||
+114
-167
@@ -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" }),
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")}` },
|
||||
}
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user