Compare commits

..
Author SHA1 Message Date
Kit Langton 5b67d64f94 refactor(core): simplify Console recovery lifecycle 2026-08-27 20:11:33 -04:00
Kit Langton 79f5080431 fix(core): keep Console inventory recovery plugin-local
Remove the Catalog refresh registry and SessionContext retry path. Use SynchronizedRef, FiberHandle, and Schedule inside the Console plugin to retain successful inventories and own retry and cancellation behavior.

Cache unexpanded inventories per stored connection, restore them before HTTP revalidation, and keep persistence failures and nested catalog-policy mutations from corrupting source data. Materialize the existing OAuth registration when batched startup would otherwise resolve an expired token without its refresh implementation.
2026-08-27 19:38:49 -04:00
Kit Langton b70bdad053 fix(core): recover Console models after startup failures
A transient Console configuration fetch failure was swallowed into an empty inventory that stayed captured after networking recovered. Refresh sources separately from catalog replay, retry transient loads within a bounded startup budget, and recover failed inventories in the background with capped backoff.

Preserve same-account inventory, refresh missing selections and moved sessions, and cover startup recovery, account isolation, cancellation, and ordered policy replay with production-service tests.
2026-08-27 17:37:09 -04:00
Kit Langton f607ca4c72 fix(tui): unify attention indicators with unread accent (#45741)
Use the unread accent color for question and permission status indicators by default, preserving semantic tokens and explicit theme overrides.
2026-08-27 17:15:44 -04:00
Kit Langton 39416a0d95 test(ai): align test runners with API boundaries (#45469) 2026-08-27 17:07:02 -04:00
opencode-agent[bot]andiamdavidhill 84a012a0e9 fix(app): show grouped tool counts inline (#45603)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-08-27 21:01:38 +00:00
opencode-agent[bot]andiamdavidhill 16a0996bd4 fix(ui): replace error icon with updated svg (#45604)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-08-28 06:50:56 +10:00
Kit Langton 60d5f83ffd test(ai): reuse executor HTTP fixtures (#45468) 2026-08-27 16:12:48 -04:00
Kit Langton 1455995ac7 refactor(core): use word casing for MCP namespaces (#45618) 2026-08-27 16:11:50 -04:00
Kit Langton 52c04508a2 test(core): share controlled websearch test layer (#45465) 2026-08-27 16:09:06 -04:00
Kit Langton 5a67fcc17e test(httpapi-codegen): share emitted-module fixtures (#45463) 2026-08-27 15:53:46 -04:00
Kit Langton 73b575468e test(server): simplify scoped endpoint fixtures (#45466) 2026-08-27 15:53:30 -04:00
78 changed files with 2012 additions and 1428 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/theme": patch
---
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Keep Console model inventories available across restarts using a stored-connection cache, and recover transient fetch failures with scoped retries. Refresh and caching stay inside the Console plugin, preserve existing catalog policy, and do not persist resolved credentials.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
+7 -9
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
@@ -148,15 +148,13 @@ describe("llm route", () => {
}),
)
it.effect("builds models from configured routes", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
test("builds models from configured routes", () => {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
}),
)
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
})
it.effect("does not register duplicate route ids globally", () =>
Effect.gen(function* () {
+114 -167
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,47 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
@@ -107,19 +66,17 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(cause)
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(cause)
},
),
]),
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
),
),
)
@@ -148,15 +105,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
),
),
)
@@ -176,15 +132,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
),
),
)
@@ -200,7 +155,7 @@ describe("RequestExecutor", () => {
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([]))),
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
)
it.effect("reports the request sent by middleware", () =>
@@ -249,11 +204,9 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
),
),
)
@@ -269,7 +222,7 @@ describe("RequestExecutor", () => {
classification: "payload-too-large",
})
expect(error.reason.http?.status).toBe(413)
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
@@ -285,11 +238,9 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(413)
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
),
),
)
@@ -303,7 +254,7 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -317,15 +268,13 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toContain(largeProviderMessage)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
),
),
)
@@ -340,7 +289,7 @@ describe("RequestExecutor", () => {
_tag: "InvalidRequest",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -352,7 +301,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -369,7 +318,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -399,12 +348,10 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("rate limited")
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
),
),
)
@@ -417,7 +364,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -437,20 +384,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
),
),
)
@@ -470,20 +415,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
),
),
)
@@ -496,10 +439,14 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
),
)
@@ -522,12 +469,10 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(status)
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
),
)
@@ -538,20 +483,28 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("preserves response body fields", () =>
@@ -563,11 +516,9 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
),
),
)
@@ -581,9 +532,7 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
),
),
)
@@ -591,9 +540,7 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -624,7 +571,7 @@ describe("RequestExecutor", () => {
})
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
+17 -15
View File
@@ -44,21 +44,23 @@ describe("Tool.make (dynamic JSON Schema)", () => {
expect(definition?.inputSchema).toEqual(jsonSchema)
})
test("execute receives the raw input untouched", async () => {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = await Effect.runPromise(tool.execute({ hello: "world" }))
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
})
it.effect("execute receives the raw input untouched", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = yield* tool.execute({ hello: "world" })
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
}),
)
})
describe("LLM.generateObject", () => {
+10 -12
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
@@ -136,17 +136,15 @@ describe("Cloudflare", () => {
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
}),
)
test("defaults AI Gateway id to default when omitted or blank", () => {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
})
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
@@ -376,11 +376,9 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("rejects tuned Gemini models in express mode", () =>
Effect.sync(() => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
}),
)
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
@@ -31,82 +31,71 @@ import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
test("assigns provider-owned metadata namespaces across native routes", () => {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"), "custom"],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
})
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
test("preserves native Together AI and Cerebras provider and route identities", () => {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
})
it.effect("preserves native DeepInfra provider and route identity", () =>
Effect.gen(function* () {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
}),
)
test("preserves native DeepInfra provider and route identity", () => {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
})
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
@@ -161,39 +150,35 @@ describe("native OpenAI-compatible providers", () => {
}),
)
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
Effect.gen(function* () {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
test("normalizes DeepInfra API roots without duplicating the OpenAI path", () => {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
)
}
}),
)
)
}
})
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
test("maps package settings onto native executable models", () => {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
})
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
@@ -91,40 +91,38 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
test("provides model helpers for compatible provider families", () => {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
})
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
+38 -38
View File
@@ -26,6 +26,7 @@ import {
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
import { it } from "./lib/effect.js"
const model = new LanguageModel({
id: ModelID.make("fake-model"),
@@ -101,49 +102,48 @@ describe("AI.Usage", () => {
expect(ProviderShared.sumTokens()).toBeUndefined()
})
test("sseFraming maps decoder failures to AI errors", async () => {
const error = await Effect.runPromise(
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
Stream.runCollect,
Effect.flip,
),
)
it.effect("sseFraming maps decoder failures to AI errors", () =>
Effect.gen(function* () {
const error = yield* ProviderShared.sseFraming(
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
).pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
it.effect("sseFraming ignores retry directives without ending the stream", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect),
)
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
}),
)
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
it.effect("sseFraming preserves event data around retry directives", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect),
)
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
expect(Array.from(frames)).toEqual(["first\nsecond"])
}),
)
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
@@ -153,18 +153,18 @@ describe("AI.Usage", () => {
})
})
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
it.effect("AI errors expose the shared runtime tag", () =>
Effect.gen(function* () {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(yield* Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))).toBe("caught")
}),
)
test("transport errors serialize execution facts", () => {
const reason = new TransportError({
+67 -73
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
@@ -277,65 +277,61 @@ describe("LLMClient tools", () => {
}),
)
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content)
test("models canonical tool files with URIs", () => {
const decode = Schema.decodeUnknownSync(Content)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
}),
)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
})
it.effect("preserves canonical tool file URIs", () =>
Effect.sync(() => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
test("preserves canonical tool file URIs", () => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
}),
)
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
})
it.effect("settles projected URL files as canonical tool results", () =>
Effect.gen(function* () {
@@ -364,24 +360,22 @@ describe("LLMClient tools", () => {
}),
)
it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
Effect.sync(() => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
test("derives typed output schemas and preserves dynamic output schemas", () => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
}),
)
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
})
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
+37 -43
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { AIError } from "../src/schema/index.js"
import { ToolStream } from "../src/protocols/utils/tool-stream.js"
@@ -38,44 +38,40 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
test("exposes cumulative partial string values", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
})
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
})
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
@@ -104,14 +100,12 @@ describe("ToolStream", () => {
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
test("fails appendExisting when the provider skipped the tool start", () => {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
}),
)
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
})
it.effect("uses final input override without losing accumulated deltas", () =>
Effect.gen(function* () {
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
await expect(thinking).toHaveCount(0)
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/Used\s*Read, Grep/,
/^Used\s*2 Read, Grep$/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
])
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used Shell" })
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
@@ -93,7 +93,8 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
await expect(summary).toHaveAccessibleName("Used 1 Patch")
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
@@ -109,7 +110,8 @@ test.describe("regression: session timeline local row state", () => {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -28,7 +28,7 @@ for (const expanded of [false, true]) {
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used Shell" })
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -141,7 +141,7 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used Shell", exact: true })
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
@@ -150,7 +150,7 @@ for (const open of [false, true]) {
"aria-expanded",
String(open),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
@@ -16,8 +16,9 @@ for (const locale of ["de", "ar"] as const) {
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -105,10 +105,10 @@ for (const summaries of [false, true]) {
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toContainText("UsedSkill")
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
@@ -45,10 +45,11 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
@@ -74,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
@@ -113,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
@@ -128,7 +129,10 @@ test("combines follow-up patches into one three-file stack inside Used", async (
),
),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
@@ -158,8 +162,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
+5 -5
View File
@@ -1,11 +1,11 @@
export * as ConfigMCPPlugin from "./mcp.js"
export * as ConfigMcpPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import type { ServerConfig } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { MCP } from "../../mcp/index.js"
import { Mcp } from "../../mcp/index.js"
export const Plugin = define({
id: "opencode.config.mcp",
@@ -18,7 +18,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
events: Stream.Stream<{ readonly type: string }, unknown>,
) {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const loaded = { entries: [] as Entry[] }
yield* events.pipe(
@@ -43,7 +43,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, Mcp.ServerConfig>()
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
+2 -2
View File
@@ -23,7 +23,7 @@ import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
@@ -84,7 +84,7 @@ const locationServiceNodes = [
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
Mcp.node,
Permission.node,
Tool.node,
ToolOutput.node,
+3 -3
View File
@@ -1,4 +1,4 @@
export * as MCPClient from "./client.js"
export * as McpClient from "./client.js"
import path from "node:path"
import { pathToFileURL } from "node:url"
@@ -29,7 +29,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
@@ -222,7 +222,7 @@ export const connect = Effect.fnUntraced(function* (
const exit = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server,
command,
args,
+30 -30
View File
@@ -1,4 +1,4 @@
export * as MCP from "./index.js"
export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
@@ -16,7 +16,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
import { Location } from "../location.js"
import { waitForAbort } from "@opencode-ai/util/process"
import { State } from "../state.js"
import type { MCPClient } from "./client.js"
import type { McpClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
@@ -114,7 +114,7 @@ type ServerEntry = {
status: Status
readonly startup: Latch.Latch
scope?: Scope.Closeable
client?: MCPClient.Connection
client?: McpClient.Connection
tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
@@ -235,8 +235,8 @@ export const layer = (options?: Options) =>
method: { id: methodID, type: "oauth", label: name },
authorize: () =>
Effect.gen(function* () {
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* McpOAuth.authorize({ name, config: remote, methodID })
}),
})
})
@@ -254,7 +254,7 @@ export const layer = (options?: Options) =>
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const base = {
@@ -270,7 +270,7 @@ export const layer = (options?: Options) =>
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
const integrationID = entry.integrationID
@@ -282,7 +282,7 @@ export const layer = (options?: Options) =>
const match = stored.find((credential) => credential.id === credentialID)
return match && match.value.type === "oauth" ? match.value : undefined
}
return MCPOAuth.provider({
return McpOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
@@ -303,22 +303,22 @@ export const layer = (options?: Options) =>
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return MCPOAuth.toTokens(oauth)
return McpOAuth.toTokens(oauth)
},
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
const value = MCPOAuth.toCredential({
const value = McpOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
},
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
@@ -330,7 +330,7 @@ export const layer = (options?: Options) =>
const elicitation = {
create: (input: {
readonly server: string
readonly params: MCPClient.ElicitationParams
readonly params: McpClient.ElicitationParams
readonly signal: AbortSignal
}) =>
Effect.gen(function* () {
@@ -355,7 +355,7 @@ export const layer = (options?: Options) =>
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.map(
(state): MCPClient.ElicitationResult => ({
(state): McpClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
}),
),
@@ -375,13 +375,13 @@ export const layer = (options?: Options) =>
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.map((state): MCPClient.ElicitationResult => {
Effect.map((state): McpClient.ElicitationResult => {
if (state.status !== "answered") return { action: "cancel" }
return {
action: "accept",
content: Object.fromEntries(
Object.entries(state.answer).map(
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
typeof value === "object" ? [key, Array.from(value)] : [key, value],
),
),
@@ -395,9 +395,9 @@ export const layer = (options?: Options) =>
if (!formID) return
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
} satisfies McpClient.ElicitationHandler
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
new Tool({
server,
name: def.name,
@@ -407,7 +407,7 @@ export const layer = (options?: Options) =>
outputSchema: def.outputSchema,
})
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
@@ -422,7 +422,7 @@ export const layer = (options?: Options) =>
),
})
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
@@ -431,7 +431,7 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
@@ -440,14 +440,14 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, entry, def))
}),
)
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.prompts().pipe(
Effect.orElseSucceed(() => []),
Effect.map((defs) => {
@@ -459,7 +459,7 @@ export const layer = (options?: Options) =>
// Runs a connection callback under the server lock, dropping it if the connection is no longer
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
const whenLive =
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
<E>(effect: Effect.Effect<void, E>) =>
fork(
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
@@ -468,7 +468,7 @@ export const layer = (options?: Options) =>
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
live(
@@ -491,7 +491,7 @@ export const layer = (options?: Options) =>
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
}
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
const fields = { server, logger: message.logger, level: message.level, data: message.data }
switch (message.level) {
case "debug":
@@ -518,10 +518,10 @@ export const layer = (options?: Options) =>
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(
const result = yield* McpClient.connect(
name,
entry.config,
location.directory,
@@ -555,7 +555,7 @@ export const layer = (options?: Options) =>
entry.scope = undefined
const error = Cause.squash(result.cause)
entry.status =
error instanceof MCPClient.NeedsAuthError
error instanceof McpClient.NeedsAuthError
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
@@ -938,4 +938,4 @@ function toElicitationField(key: string, property: ElicitationProperty, required
}
}
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
+3 -3
View File
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { McpTool } from "../tool/mcp.js"
import { MCP } from "./index.js"
import { Mcp } from "./index.js"
import { Instructions } from "../instructions/index.js"
const Summary = Schema.Struct({
@@ -63,7 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return Service.of({
load: Effect.fn("McpInstructions.load")(function* (selection) {
@@ -110,4 +110,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPOAuth from "./oauth.js"
export * as McpOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPStdio from "./stdio.js"
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
+2 -2
View File
@@ -13,7 +13,7 @@ import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
@@ -197,7 +197,7 @@ export const node = makeLocationNode({
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
Location.node,
Reference.node,
Skill.node,
+11 -13
View File
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,20 +12,18 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
const loaded = { prompts: [] as Mcp.Prompt[] }
yield* bus.subscribe(Mcp.PromptsChanged).pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
Effect.forkScoped({ startImmediately: true }),
)
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
+4 -4
View File
@@ -3,7 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -16,7 +16,7 @@ import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
@@ -41,7 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const skill = yield* Skill.Service
@@ -295,7 +295,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
+8 -8
View File
@@ -18,7 +18,7 @@ import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
@@ -44,7 +44,7 @@ import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
@@ -78,7 +78,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -114,7 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -158,7 +158,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Mcp.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -209,7 +209,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Mcp.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -234,8 +234,8 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
VcsGitPlugin.Plugin,
AgentPlugin.Plugin,
@@ -1,4 +1,4 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
export * as McpCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
+154 -26
View File
@@ -1,8 +1,8 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import { Cause, Duration, Effect, Exit, Latch, Option, Schedule, Schema, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
@@ -12,11 +12,31 @@ import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
import { isDeepStrictEqual } from "node:util"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const CachedInventory = Schema.fromJsonString(
Schema.Record(Schema.String, ConfigProviderV1.Info).check(
Schema.makeFilter((providers) =>
Object.values(providers).every(
(provider) =>
cacheableURL(provider.api) &&
cacheable(provider.options) &&
Object.values(provider.models ?? {}).every(
(model) =>
cacheableURL(model.provider?.api) &&
cacheable(model.options) &&
cacheable({ headers: model.headers }) &&
Object.values(model.variants ?? {}).every(cacheable),
),
),
),
),
)
const placeholder = /^(?:Bearer )?\{env:[a-z_][a-z_0-9]*\}$/i
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -87,25 +107,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
connected = connection !== undefined
providers = credential
? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
})
yield* ctx.integration.transform((draft) => {
draft.update("opencode", (integration) => {
integration.name = "OpenCode"
@@ -114,9 +115,59 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
yield* load()
const read = Effect.fn("OpencodePlugin.readCache")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
// Stored connection IDs survive token refresh and separate accounts, servers, and organizations.
const cached =
connection?.type === "credential"
? yield* ctx.storage
.get(`inventory:${connection.id}`)
.pipe(
Effect.catchDefect((cause) =>
Effect.logWarning("failed to read Console inventory cache", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
return { connection, providers: Option.getOrUndefined(Schema.decodeUnknownOption(CachedInventory)(cached)) }
})
let inventory = yield* read()
const ready = yield* Latch.make()
const refresh = Effect.fn("OpencodePlugin.refresh")(function* () {
// Activation batches transforms; materialize OAuth refresh before resolution, but not before cache restore.
if (inventory.connection?.type === "credential") {
const registered = yield* ctx.integration
.get({ integrationID: Integration.ID.make("opencode") })
.pipe(Effect.orElseSucceed(() => undefined))
if (!registered?.data?.methods.some((method) => method.type === "oauth" && method.id === methodID))
yield* ctx.integration.reload()
}
const providers = inventory.connection
? yield* ctx.integration.connection.resolve(inventory.connection).pipe(
Effect.flatMap((credential) =>
credential
? fetchProviders(http, credential).pipe(Effect.map((providers) => providers ?? {}))
: Effect.undefined,
),
Effect.retry({ while: retryable, times: 2, schedule: Schedule.exponential(200) }),
Effect.timeout("5 seconds"),
)
: undefined
if (isDeepStrictEqual(inventory.providers, providers)) return
inventory = { connection: inventory.connection, providers }
yield* ctx.catalog.reload()
if (inventory.connection?.type !== "credential" || providers === undefined) return
const cached = Schema.encodeOption(CachedInventory)(providers)
yield* (
Option.isSome(cached)
? ctx.storage.set(`inventory:${inventory.connection.id}`, cached.value)
: ctx.storage.remove(`inventory:${inventory.connection.id}`)
).pipe(Effect.catchDefect((cause) => Effect.logWarning("failed to persist Console inventory cache", { cause })))
})
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
// Later transforms may mutate nested settings; keep the source inventory independent of catalog policy.
for (const [providerID, item] of Object.entries(structuredClone(inventory.providers ?? {}))) {
catalog.provider.update(providerID, (provider) => {
provider.integrationID = Integration.ID.make("opencode")
if (item.name !== undefined) provider.name = item.name
@@ -176,7 +227,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const item = catalog.provider.get(Provider.ID.opencode)
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
const hasKey = Boolean(process.env.OPENCODE_API_KEY || inventory.connection || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) {
provider.activation = "enabled"
@@ -192,15 +243,92 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
// Switching waits for the previous refresh to stop, so only one worker writes the captured inventory.
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Stream.prepend([undefined]),
Stream.switchMap((event) =>
Stream.fromEffect(
Effect.gen(function* () {
if (event) {
inventory = yield* read()
yield* ctx.catalog.reload()
}
if (inventory.providers !== undefined) yield* ready.open
yield* refresh().pipe(
Effect.tapError((cause) => Effect.logWarning("failed to load OpenCode provider config", { cause })),
Effect.onExit((exit) =>
Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) ? Effect.void : ready.open,
),
Effect.retry({
while: retryable,
schedule: Schedule.min([Schedule.exponential("5 seconds"), Schedule.spaced("30 seconds")]),
}),
Effect.ignore,
)
}),
),
),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
yield* ready.await
}),
})
function cacheable(value: unknown): boolean {
if (Array.isArray(value)) return value.every(cacheable)
if (value === null || typeof value !== "object") return true
return Object.entries(value).every(([key, item]) => {
const name = key.replace(/[-_]/g, "").toLowerCase()
if (name === "headers") {
if (item === undefined) return true
if (item === null || typeof item !== "object" || Array.isArray(item)) return false
return Object.entries(item).every(
([header, content]) =>
typeof content === "string" &&
(placeholder.test(content) ||
[
"x-org-id",
"anthropic-version",
"anthropic-beta",
"content-type",
"accept",
"openai-organization",
"openai-project",
].includes(header.toLowerCase())),
)
}
if (
/^(?:apiKey|xApiKey|xGoogApiKey|authorization|accessToken|authToken|refreshToken|password|secret|credentials|cookie|setCookie)$/i.test(
name,
)
)
return typeof item === "string" && placeholder.test(item)
if (name === "baseurl" || name === "enterpriseurl") return typeof item === "string" && cacheableURL(item)
return cacheable(item)
})
}
function cacheableURL(value: string | undefined) {
if (value === undefined) return true
const url = URL.parse(value)
return url !== null && !url.username && !url.password && !url.search && !url.hash
}
function retryable(cause: unknown): boolean {
if (cause instanceof Integration.AuthorizationError) return retryable(cause.cause)
if (Cause.isTimeoutError(cause)) return true
if (!HttpClientError.isHttpClientError(cause)) return false
return (
cause.reason._tag === "TransportError" ||
(cause.reason._tag === "StatusCodeError" &&
(cause.reason.response.status === 408 ||
cause.reason.response.status === 429 ||
cause.reason.response.status >= 500))
)
}
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
+3 -3
View File
@@ -6,7 +6,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Session } from "../session.js"
export interface Interface {
@@ -38,7 +38,7 @@ export interface Interface {
readonly mcp: {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Mcp.ServerInfo[] }, unknown>
}
}
}
@@ -130,7 +130,7 @@ export const providerLayerWithCell = (cell: Cell) =>
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
+4 -4
View File
@@ -6,7 +6,7 @@ import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } fro
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Permission } from "../permission.js"
import { Tool } from "../tool.js"
@@ -26,12 +26,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const lock = Semaphore.makeUnsafe(1)
let discovered: MCP.Tool[] = []
let discovered: Mcp.Tool[] = []
// Register once after initial discovery; only subsequent updates need a debounced reload.
const initial = yield* lock
@@ -134,5 +134,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
deps: [Tool.node, Mcp.node, Bus.node, Permission.node],
})
+3 -5
View File
@@ -19,7 +19,7 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@@ -43,7 +43,7 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
[MCP.node, emptyMcpLayer],
[Mcp.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
@@ -431,9 +431,7 @@ function sourceCases() {
{
name: "updated",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first"))),
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
+4 -4
View File
@@ -1,13 +1,13 @@
import { Effect, Layer, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./location"
export const emptyMcpLayer = Layer.succeed(
MCP.Service,
MCP.Service.of({
Mcp.Service,
Mcp.Service.of({
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
servers: () => Effect.succeed([]),
@@ -20,7 +20,7 @@ export const emptyMcpLayer = Layer.succeed(
instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]),
prompt: () => Effect.undefined,
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.undefined,
}),
)
+44
View File
@@ -0,0 +1,44 @@
export * as TestWebSearch from "./websearch"
import { Context, Deferred, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
export interface Interface extends WebSearch.Interface {
readonly queries: readonly WebSearch.Input[]
/** Waits for query arrivals, not provider execution or query completion. */
readonly wait: (count: number) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
// No providers are installed: tests register local executors through transform.
// The normal Bus and KV implementations use the default in-memory database.
export const layer = Layer.effectContext(
Effect.gen(function* () {
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const websearch = Context.get(context, WebSearch.Service)
const queries: WebSearch.Input[] = []
let started = yield* Deferred.make<void>()
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
queries.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(() => wait(count))),
)
const test = Service.of({
...websearch,
queries,
wait,
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
queries.push({ ...input })
const previous = started
started = yield* Deferred.make<void>()
yield* Deferred.succeed(previous, undefined)
return yield* websearch.query(input)
}),
})
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
}),
)
+2 -2
View File
@@ -31,7 +31,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -998,7 +998,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
yield* supervisor.flush
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
+8 -8
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { Permission } from "@opencode-ai/core/permission"
import { McpTool } from "@opencode-ai/core/tool/mcp"
@@ -17,15 +17,15 @@ const selection = (permissions: Permission.Ruleset = []) => {
}
const instructions = (server: string, text: string) =>
new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
new Mcp.ServerInstructions({ server: Mcp.ServerName.make(server), instructions: text })
const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.ServerName.make(server), name })
const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
AppNodeBuilder.build(McpInstructions.node, [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
instructions: () => Effect.succeed(catalog()),
tools: () => Effect.succeed(tools()),
}),
@@ -113,7 +113,7 @@ describe("McpInstructions", () => {
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
() => [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })],
),
),
),
@@ -125,7 +125,7 @@ describe("McpInstructions", () => {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
tools = [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })]
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
expect(changed.text).toBe(
[
+2 -2
View File
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test"
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Integration } from "@opencode-ai/core/integration"
import { MCPOAuth } from "@opencode-ai/core/mcp/oauth"
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
import { Effect } from "effect"
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
@@ -12,7 +12,7 @@ const authorize = (redirect_uri?: string) =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* MCPOAuth.authorize({
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
+61 -61
View File
@@ -16,7 +16,7 @@ import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Config } from "@opencode-ai/core/config"
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { ConfigMcpPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -27,9 +27,9 @@ import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpClient } from "@opencode-ai/core/mcp/client"
import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -180,7 +180,7 @@ function resourceServer(
function resourceMcpLayer(
server: string | typeof ConfigMCP.Server.Type,
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
options?: MCP.Options,
options?: Mcp.Options,
overrides?: {
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
@@ -193,10 +193,10 @@ function resourceMcpLayer(
return Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* ConfigMCPPlugin.register(bus.subscribe())
yield* ConfigMcpPlugin.register(bus.subscribe())
}),
).pipe(
Layer.provideMerge(MCP.layer(options)),
Layer.provideMerge(Mcp.layer(options)),
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
@@ -267,13 +267,13 @@ function resourceMcpLayer(
}
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
const mcp = Layer.mock(MCP.Service, {
const mcp = Layer.mock(Mcp.Service, {
tools: () =>
Effect.succeed([
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "search",
description: "Search",
inputSchema: { type: "object", properties: {} },
@@ -283,28 +283,28 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
codemode: false,
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "fail",
codemode: false,
description: "Always fails",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "media",
codemode: false,
description: "Returns text and an image",
@@ -315,15 +315,15 @@ const mcp = Layer.mock(MCP.Service, {
Effect.sync(() => {
calls += 1
if (input.name === "fail")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: true,
content: [{ type: "text", text: "search index unavailable" }],
})
if (input.name === "media")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [
@@ -332,14 +332,14 @@ const mcp = Layer.mock(MCP.Service, {
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
structured: { ok: true },
@@ -358,7 +358,7 @@ const permissions = Layer.mock(Permission.Service, {
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[MCP.node, mcp],
[Mcp.node, mcp],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
@@ -367,12 +367,12 @@ const it = testEffect(
describe("MCP errors", () => {
test("expose useful messages", () => {
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(new Mcp.NotFoundError({ server: Mcp.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
new Mcp.ToolCallError({ server: Mcp.ServerName.make("demo"), tool: "search", message: "failed" }).message,
).toBe("failed")
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
expect(new McpClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new McpClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
})
})
@@ -488,7 +488,7 @@ test("spawns local MCP servers through the location environment", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
const connection = yield* McpClient.connect("environment", config, import.meta.dir)
yield* connection.tools()
}),
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
@@ -513,7 +513,7 @@ test("reports a local MCP server as failed when the location has no execution pl
await Effect.runPromise(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.tools()
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
expect(status).toEqual({
@@ -528,7 +528,7 @@ test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "not-started",
command: process.execPath,
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
@@ -551,7 +551,7 @@ test("joins concurrent stdio transport closes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "concurrent-close",
command: "unused",
args: [],
@@ -605,7 +605,7 @@ test("closes a stdio process that finishes spawning after close", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server: "close-during-spawn",
command: "unused",
args: [],
@@ -816,7 +816,7 @@ for (const entry of [
})
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
expect(error).toBeInstanceOf(MCPClient.ConnectError)
expect(error).toBeInstanceOf(McpClient.ConnectError)
expect(server.state.initializations).toBe(entry.attempts)
expect(server.state.urls).toHaveLength(entry.attempts)
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
@@ -956,7 +956,7 @@ test("accepts empty MCP elicitations without creating forms", async () => {
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const forms = yield* Form.Service
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
expect(yield* forms.list()).toEqual([])
@@ -976,7 +976,7 @@ test("acknowledges completed MCP URL elicitations without returning internal con
const server = yield* resourceServer({ resources: false, urlElicitation: true })
const created = yield* Deferred.make<Form.Info>()
const result = yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const forms = yield* Form.Service
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
@@ -1006,7 +1006,7 @@ test("loads and reads MCP resources", async () => {
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect(yield* service.resourceCatalog()).toEqual({
resources: [
{
@@ -1053,12 +1053,12 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
Effect.scoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(published).toContain(McpEvent.StatusChanged.type)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
yield* service.add(
"dynamic",
new ConfigMCP.Local({
@@ -1101,7 +1101,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
yield* service.remove("dynamic")
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
expect(yield* service.tools()).toEqual([])
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
}).pipe(
Effect.provide(
resourceMcpLayer(
@@ -1125,7 +1125,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
"manages live MCP servers entirely through scoped transforms",
() =>
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* Effect.scoped(
Effect.gen(function* () {
@@ -1168,7 +1168,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
}),
)
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
expect(yield* service.tools()).toEqual([])
}),
)
@@ -1177,7 +1177,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
@@ -1220,7 +1220,7 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
yield* service.transform((draft) =>
draft.update("resources", (server) => {
@@ -1263,7 +1263,7 @@ test("reconciles only changed MCP server config", async () => {
} satisfies Payload<typeof Event.Updated>)
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.tools()
expect(server.state.toolLists).toBe(1)
expect(server.state.initializations).toBe(1)
@@ -1330,7 +1330,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
Effect.scoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
// Whatever order the racing operations land in, the resulting state must be consistent.
yield* Effect.all(
@@ -1373,8 +1373,8 @@ test("serializes concurrent MCP lifecycle operations", async () => {
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string, description = name) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
new Mcp.Tool({
server: Mcp.ServerName.make(server),
name,
description,
codemode: false,
@@ -1498,13 +1498,13 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
Effect.succeed(
new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
@@ -1541,12 +1541,12 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
Mcp.node,
Layer.mock(Mcp.Service, {
tools: () =>
Effect.sync(() => [
new MCP.Tool({
server: MCP.ServerName.make("demo"),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: `read_${++reads}`,
codemode: false,
inputSchema: { type: "object", properties: {} },
+3 -3
View File
@@ -4,7 +4,7 @@ import { Command } from "@opencode-ai/core/command"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/schema/session"
@@ -24,8 +24,8 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
[MCP.node, emptyMcpLayer],
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
[Mcp.node, emptyMcpLayer],
[Location.node, locationLayer],
]),
)
+3 -3
View File
@@ -14,7 +14,7 @@ import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -72,7 +72,7 @@ export const PluginTestLayer = LayerNode.compile(
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
PluginRuntime.node,
Permission.node,
PluginHooks.node,
@@ -89,7 +89,7 @@ export const PluginTestLayer = LayerNode.compile(
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Config.testLayer()],
[MCP.node, emptyMcpLayer],
[Mcp.node, emptyMcpLayer],
[Generate.node, generateLayer],
[Permission.node, permissionLayer],
],
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { MCPCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
import { McpCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
import type { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, type Types } from "effect"
import { it } from "../lib/effect"
@@ -51,7 +51,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
)
const base = host()
yield* MCPCodeModeExclusionPlugin.Plugin.effect(
yield* McpCodeModeExclusionPlugin.Plugin.effect(
host({
mcp: {
...base.mcp,
@@ -0,0 +1,315 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Provider } from "@opencode-ai/core/provider"
import { State } from "@opencode-ai/core/state"
import { Effect, Exit, Fiber, Schedule, Schema, Scope } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const providerID = Provider.ID.make("example")
const modelID = Model.ID.make("chat")
const hiddenID = Model.ID.make("hidden")
const placeholder = "{env:OPENCODE_CONSOLE_TOKEN}"
const headers = { Authorization: `Bearer ${placeholder}`, "x-org-id": "org-a" }
const variant = {
apiKey: placeholder,
headers,
temperature: 0.2,
reasoning: { effort: "high", budget: { tokens: 2048 } },
response: { format: { type: "json", required: ["answer", "source"] } },
}
const addPlugin = Effect.fn(function* (set?: (key: string, value: Schema.Json) => Effect.Effect<void>) {
const scope = yield* Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit))
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin, OpencodePlugin.id)
yield* State.batch(
OpencodePlugin.effect(set ? { ...host, storage: { ...host.storage, set } } : host).pipe(Scope.provide(scope)),
)
return { host, scope }
})
const serve = (fetch: (request: Request) => Response | Promise<Response>) =>
Effect.acquireRelease(
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })),
(server) => Effect.promise(() => server.stop(true)),
)
const connect = Effect.fn(function* (server: string, key = "fixture-key") {
const credentials = yield* Credential.Service
return yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key, metadata: { server, orgID: "org-a" } }),
})
})
function eventually<A, E, R>(effect: Effect.Effect<A, E, R>, until: (value: A) => boolean) {
return effect.pipe(Effect.repeat({ until, schedule: Schedule.spaced("10 millis") }), Effect.timeout("2 seconds"))
}
function inventory(origin: string, output = 1000, apiKey = placeholder) {
return Response.json({
config: {
provider: {
example: {
name: "Example Console",
npm: "@ai-sdk/openai-compatible",
api: `${origin}/v1`,
options: { apiKey, headers },
models: {
chat: {
name: "Example Chat",
family: "example-chat",
release_date: "2026-01-02",
tool_call: true,
modalities: { input: ["text", "image"], output: ["text"] },
cost: { input: 1, output: 2, cache_read: 0.1, cache_write: 0.2 },
limit: { context: 10000, output },
options: { apiKey: placeholder, temperature: 0.5 },
variants: { careful: variant },
},
hidden: { name: "Hidden Chat" },
},
},
},
},
})
}
describe("OpencodePlugin inventory cache", () => {
it.live("restores metadata and nested variants before HTTP completes, then replays catalog policy", () =>
Effect.gen(function* () {
const gate = { enabled: false }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const paths: string[] = []
const server = yield* serve(async (request) => {
paths.push(new URL(request.url).pathname)
if (!gate.enabled) return inventory(new URL(request.url).origin)
requested.resolve()
await release.promise
if (new URL(request.url).pathname === "/auth/device/token")
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", expires_in: 3600 })
if (request.headers.get("authorization") !== "Bearer rotated-access")
return new Response("Expired credential", { status: 401 })
return inventory(new URL(request.url).origin, 2000)
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const credentials = yield* Credential.Service
const account = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "fixture-access-token",
refresh: "fixture-refresh-token",
expires: Date.now() + 3_600_000,
metadata: { server: server.url.origin, accountID: "account-a", orgID: "org-a" },
}),
})
const catalog = yield* Catalog.Service
const first = yield* addPlugin()
const saved = yield* first.host.storage.scan({ prefix: "" })
expect(saved.entries).toHaveLength(1)
expect(JSON.stringify(saved)).toContain(placeholder)
expect(JSON.stringify(saved)).not.toContain("fixture-access-token")
expect(JSON.stringify(saved)).not.toContain("fixture-refresh-token")
yield* Scope.close(first.scope, Exit.void)
expect(yield* catalog.model.available()).toEqual([])
if (account.value.type !== "oauth") return yield* Effect.die("Expected OAuth credential")
yield* credentials.update(account.id, { value: Credential.OAuth.make({ ...account.value, expires: 0 }) })
gate.enabled = true
const persisted = Promise.withResolvers<Schema.Json>()
yield* addPlugin((_key, value) => Effect.sync(() => persisted.resolve(value))).pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
expect(paths.at(-1)).toBe("/auth/device/token")
expect(yield* catalog.provider.get(providerID)).toMatchObject({
name: "Example Console",
integrationID: "opencode",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1` },
headers,
})
const cached = yield* catalog.model.get(providerID, modelID)
expect(cached).toMatchObject({
name: "Example Chat",
family: "example-chat",
time: { released: Date.parse("2026-01-02") },
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
limit: { context: 10000, output: 1000 },
settings: { temperature: 0.5 },
})
expect(cached?.variants).toEqual([
{
id: Model.VariantID.make("careful"),
headers,
settings: { temperature: 0.2, reasoning: variant.reasoning, response: variant.response },
},
])
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
yield* catalog.transform((draft) => {
draft.model.remove(providerID, hiddenID)
draft.model.update(providerID, modelID, (model) => {
model.name = "Policy Chat"
const reasoning = model.variants?.find((variant) => variant.id === "careful")?.settings?.reasoning
if (typeof reasoning !== "object" || reasoning === null || !("effort" in reasoning))
throw new Error("Expected reasoning options")
reasoning.effort = "low"
})
})
release.resolve()
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.output === 2000)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Policy Chat")
expect(yield* catalog.model.get(providerID, hiddenID)).toBeUndefined()
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([modelID])
expect(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(yield* Effect.promise(() => persisted.promise)),
).toMatchObject({
example: {
models: { chat: { limit: { output: 2000 }, variants: { careful: { reasoning: { effort: "high" } } } } },
},
})
}),
)
it.live("isolates accounts, restores the previous cache offline, and cancels obsolete workers", () =>
Effect.gen(function* () {
const gate = { enabled: false }
const release = Promise.withResolvers<void>()
const requests: { authorization: string | null; aborted: boolean }[] = []
const server = yield* serve(async (request) => {
const entry = { authorization: request.headers.get("authorization"), aborted: false }
requests.push(entry)
request.signal.addEventListener("abort", () => (entry.aborted = true), { once: true })
if (gate.enabled) await release.promise
return inventory(new URL(request.url).origin)
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const account = yield* connect(server.url.origin, "fixture-account-a")
const first = yield* addPlugin()
expect(JSON.stringify(yield* first.host.storage.scan({ prefix: "" }))).not.toContain("fixture-account-a")
yield* Scope.close(first.scope, Exit.void)
gate.enabled = true
const second = yield* addPlugin().pipe(Effect.timeout("2 seconds"))
yield* eventually(
Effect.sync(() => requests.length),
(count) => count === 2,
)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
yield* connect(server.url.origin, "fixture-account-b")
yield* eventually(
Effect.sync(() => requests.length),
(count) => count === 3,
)
yield* eventually(
Effect.sync(() => requests[1]?.aborted),
Boolean,
)
yield* eventually(catalog.model.available(), (models) => models.length === 0)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
yield* credentials.activate(account.id)
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.name === "Example Chat")
yield* eventually(
Effect.sync(() => requests.length),
(count) => count === 4,
)
yield* eventually(
Effect.sync(() => requests[2]?.aborted),
Boolean,
)
expect(requests.map((request) => request.authorization)).toEqual([
"Bearer fixture-account-a",
"Bearer fixture-account-a",
"Bearer fixture-account-b",
"Bearer fixture-account-a",
])
yield* Scope.close(second.scope, Exit.void).pipe(Effect.timeout("2 seconds"))
yield* eventually(
Effect.sync(() => requests[3]?.aborted),
Boolean,
)
expect(yield* catalog.model.available()).toEqual([])
}),
)
it.live("publishes fresh inventory even when cache persistence dies", () =>
Effect.gen(function* () {
const server = yield* serve((request) => inventory(new URL(request.url).origin))
yield* connect(server.url.origin)
const writes: string[] = []
const instance = yield* addPlugin(() =>
Effect.sync(() => writes.push("attempted")).pipe(Effect.andThen(Effect.die(new Error("Cache write failed")))),
)
const catalog = yield* Catalog.Service
expect(writes).toEqual(["attempted"])
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
expect((yield* instance.host.storage.scan({ prefix: "" })).entries).toEqual([])
}),
)
it.live("ignores malformed cached data and replaces it with a fresh response", () =>
Effect.gen(function* () {
const gate = { enabled: false }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const server = yield* serve(async (request) => {
if (!gate.enabled) return inventory(new URL(request.url).origin)
requested.resolve()
await release.promise
return inventory(new URL(request.url).origin, 2000)
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
yield* connect(server.url.origin)
const first = yield* addPlugin()
const saved = yield* first.host.storage.scan({ prefix: "" })
expect(saved.entries).toHaveLength(1)
const entry = saved.entries[0]
if (!entry) return yield* Effect.die("Expected cached inventory")
yield* Scope.close(first.scope, Exit.void)
yield* first.host.storage.set(entry.key, "malformed cache")
gate.enabled = true
const loading = yield* addPlugin().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
const catalog = yield* Catalog.Service
expect(yield* catalog.model.available()).toEqual([])
release.resolve()
const second = yield* Fiber.join(loading).pipe(Effect.timeout("2 seconds"))
expect((yield* catalog.model.get(providerID, modelID))?.limit.output).toBe(2000)
const replaced = yield* second.host.storage.scan({ prefix: "" })
expect(replaced.entries).toHaveLength(1)
expect(replaced.entries[0]?.value).not.toBe("malformed cache")
}),
)
it.live("uses literal-credential config live without persisting its inventory", () =>
Effect.gen(function* () {
const server = yield* serve((request) => inventory(new URL(request.url).origin, 1000, "fixture-literal-key"))
yield* connect(server.url.origin)
const instance = yield* addPlugin()
const catalog = yield* Catalog.Service
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
expect((yield* instance.host.storage.scan({ prefix: "" })).entries).toEqual([])
}),
)
})
@@ -0,0 +1,224 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Provider } from "@opencode-ai/core/provider"
import { State } from "@opencode-ai/core/state"
import { Effect, Fiber, Option, Stream } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const providerID = Provider.ID.make("example")
const modelID = Model.ID.make("chat")
const hiddenID = Model.ID.make("hidden")
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin, OpencodePlugin.id)
yield* State.batch(OpencodePlugin.effect(host))
})
const connect = Effect.fn(function* (respond: (request: Request, attempt: number) => Response | Promise<Response>) {
const requests: number[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) => {
requests.push(performance.now())
return respond(request, requests.length)
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "test-key", metadata: { server: server.url.origin } }),
})
return requests
})
const inventory = () =>
Response.json({
config: {
provider: {
example: {
name: "Example",
npm: "@ai-sdk/openai-compatible",
models: { chat: { name: "Example Chat" }, hidden: { name: "Hidden Chat" } },
},
},
},
})
describe("OpencodePlugin source recovery", () => {
it.live("retries an initial 503 before plugin setup completes", () =>
Effect.gen(function* () {
const requests = yield* connect((_request, attempt) =>
attempt === 1 ? new Response("Unavailable", { status: 503 }) : inventory(),
)
const catalog = yield* Catalog.Service
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(2)
expect(requests.at(1)).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 180)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
}),
)
it.live(
"backs off repeated background failures before recovering",
() =>
Effect.gen(function* () {
const requests = yield* connect((_request, attempt) =>
attempt < 7 ? new Response("Unavailable", { status: 503 }) : inventory(),
)
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
yield* addPlugin()
const published = yield* bus
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Fiber.join(published)
expect(requests).toHaveLength(7)
expect(requests.at(6)).toBeGreaterThanOrEqual((requests.at(5) ?? Infinity) + 9800)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
}),
20_000,
)
it.live(
"recovers after setup without changing credentials and replays later catalog policy",
() =>
Effect.gen(function* () {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const retried = Promise.withResolvers<void>()
const requests = yield* connect(async (_request, attempt) => {
if (attempt <= 3) return new Response("Unavailable", { status: 503 })
if (attempt > 4) retried.resolve()
requested.resolve()
await release.promise
return inventory()
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const before = yield* credentials.list(Integration.ID.make("opencode"))
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
const ready = performance.now()
expect(requests).toHaveLength(3)
expect(requests.at(1)).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 180)
expect(requests.at(2)).toBeGreaterThanOrEqual((requests.at(1) ?? Infinity) + 380)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
yield* catalog.transform((draft) => {
draft.model.remove(providerID, hiddenID)
if (!draft.model.get(providerID, modelID)) return
draft.model.update(providerID, modelID, (model) => {
model.name = "Policy Chat"
})
})
const published = yield* bus
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(4)
expect(requests.at(3)).toBeGreaterThanOrEqual(ready + 4800)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
release.resolve()
yield* Fiber.join(published).pipe(Effect.timeout("2 seconds"))
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Policy Chat")
expect(yield* catalog.model.get(providerID, hiddenID)).toBeUndefined()
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([modelID])
expect(yield* credentials.list(Integration.ID.make("opencode"))).toEqual(before)
expect(yield* Effect.promise(() => retried.promise).pipe(Effect.timeoutOption("5500 millis"))).toEqual(
Option.none(),
)
}),
15_000,
)
Object.entries({
"401": () => new Response("Unauthorized", { status: 401 }),
"403": () => new Response("Forbidden", { status: 403 }),
"schema decode failure": () => Response.json({ config: { provider: false } }),
}).forEach(([name, respond]) => {
it.live(`does not retry ${name} during initial load`, () =>
Effect.gen(function* () {
const requests = yield* connect(respond)
const catalog = yield* Catalog.Service
yield* addPlugin()
expect(requests).toHaveLength(1)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
}),
)
})
it.live(
"stops background retries when an exhausted outage becomes nonretryable",
() =>
Effect.gen(function* () {
const rejected = Promise.withResolvers<void>()
const retried = Promise.withResolvers<void>()
const requests = yield* connect((_request, attempt) => {
if (attempt <= 3) return new Response("Unavailable", { status: 503 })
if (attempt > 4) retried.resolve()
rejected.resolve()
return new Response("Unauthorized", { status: 401 })
})
const catalog = yield* Catalog.Service
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(3)
yield* Effect.promise(() => rejected.promise).pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(4)
expect(yield* Effect.promise(() => retried.promise).pipe(Effect.timeoutOption("5500 millis"))).toEqual(
Option.none(),
)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
}),
15_000,
)
it.live(
"bounds a pending initial load and aborts its request",
() =>
Effect.gen(function* () {
const aborted = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const requests = yield* connect(async (request) => {
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
await release.promise
return inventory()
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const catalog = yield* Catalog.Service
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(1)
expect(performance.now()).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 4800)
yield* Effect.promise(() => aborted.promise).pipe(Effect.timeout("1 second"))
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
}),
10_000,
)
})
+134 -194
View File
@@ -1,13 +1,12 @@
import { beforeEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
import { Config } from "@opencode-ai/core/config"
import { KV } from "@opencode-ai/core/kv"
import { Form } from "@opencode-ai/core/form"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Document, Info } from "@opencode-ai/schema/config"
import { Session } from "@opencode-ai/core/session"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { Tool } from "@opencode-ai/core/tool"
@@ -19,7 +18,7 @@ import { imagePassthrough } from "./lib/image"
import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { webSearchHost } from "./plugin/host"
import { produce } from "immer"
import { TestWebSearch } from "./lib/websearch"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
@@ -33,142 +32,78 @@ const webSearchToolNode = makeLocationNode({
})
const sessionID = Session.ID.make("ses_websearch_test")
const assertions: Permission.AssertInput[] = []
const queries: WebSearch.Input[] = []
const formRequests: Form.CreateInput[] = []
let selection: WebSearch.ID | "random" | false | undefined
const providers = [
{ id: WebSearch.ID.make("exa"), name: "Exa" },
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
]
let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let queryError: WebSearch.Error | undefined
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
beforeEach(() => {
assertions.length = 0
queries.length = 0
formRequests.length = 0
selection = undefined
providerRequired = false
formResponse = { status: "cancelled" }
formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
queryError = undefined
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
})
class Fixture {
assertions: Permission.AssertInput[] = []
events: string[] = []
formRequests: Form.CreateInput[] = []
formResponse: Form.TerminalState = { status: "cancelled" }
formResponses: Form.TerminalState[] = []
formWait = Effect.void
error: HttpClientError.HttpClientError | undefined
results: readonly WebSearch.Result[] = [
{ url: "https://example.com", title: "Search results", content: "search results", time: {} },
]
}
const permission = permissionLayer({
assert: (input) => Effect.sync(() => assertions.push(input)),
})
const websearch = Layer.succeed(
WebSearch.Service,
WebSearch.Service.of({
transform: (transform) =>
Effect.sync(() => {
transform({
add: () => undefined,
default: {
get: () => selection,
set: (next) => (selection = next),
},
})
return { dispose: Effect.void }
}),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers),
default: () =>
Effect.gen(function* () {
if (selection === false) return yield* new WebSearch.DisabledError()
return selection ? providers.find((provider) => provider.id === selection) : undefined
}),
select: (next) => Effect.sync(() => (selection = next)),
query: (input) =>
Effect.gen(function* () {
queries.push(input)
if (queryBarrier && synchronizedQueries < 5) {
synchronizedQueries++
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
yield* Deferred.await(queryBarrier)
}
if (queryError) return yield* queryError
if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError()
if (selection)
return new WebSearch.Response({
providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection),
results: result.results,
})
return result
}),
}),
)
const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: (input) =>
Effect.sync(() => {
formRequests.push(input)
return formResponses.shift() ?? formResponse
}),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Document({
type: "document",
info: new Info({
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
const it = testEffect(TestWebSearch.layer)
const setup = Effect.gen(function* () {
const fixture = new Fixture()
const websearch = yield* TestWebSearch.Service
const kv = yield* KV.Service
yield* websearch.transform((draft) =>
providers.forEach((provider) =>
draft.add({
...provider,
execute: () =>
Effect.gen(function* () {
fixture.events.push("query")
if (fixture.error) return yield* fixture.error
return fixture.results
}),
}),
),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [
[
Permission.node,
permissionLayer({
assert: (input) =>
Effect.sync(() => {
fixture.events.push("permission")
fixture.assertions.push(input)
}),
}),
]),
update: (update) =>
Effect.sync(() => {
const info = produce(
new Info({
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
}),
update,
)
selection = info.websearch === false ? false : info.websearch?.provider
return info
}),
changes: () => Stream.never,
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
[Permission.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[Config.node, config],
[Image.node, imagePassthrough],
]),
)
],
[WebSearch.node, Layer.succeed(WebSearch.Service, websearch)],
[
Form.node,
Layer.mock(Form.Service, {
ask: (input) =>
Effect.gen(function* () {
fixture.formRequests.push(input)
yield* fixture.formWait
return fixture.formResponses.shift() ?? fixture.formResponse
}),
}),
],
[Image.node, imagePassthrough],
]),
)
return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })
})
describe("WebSearchTool registration", () => {
it.effect("asserts permission before delegating to WebSearch", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const fixture = yield* setup
const registry = fixture.registry
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
expect(
@@ -186,7 +121,7 @@ describe("WebSearchTool registration", () => {
status: "completed",
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
})
expect(assertions).toMatchObject([
expect(fixture.assertions).toMatchObject([
{
sessionID,
action: "websearch",
@@ -195,28 +130,29 @@ describe("WebSearchTool registration", () => {
metadata: { query: "effect typescript" },
},
])
expect(queries).toEqual([
expect(fixture.websearch.queries).toEqual([
{
query: "effect typescript",
providerID: WebSearch.ID.make("exa"),
},
])
expect(fixture.events).toEqual(["permission", "query"])
}),
)
it.effect("keeps normalized results in structured output", () =>
Effect.gen(function* () {
result = new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
})
const registry = yield* Tool.Service
const fixture = yield* setup
yield* fixture.websearch.select(WebSearch.ID.make("parallel"))
fixture.results = [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
]
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -250,8 +186,10 @@ describe("WebSearchTool registration", () => {
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
const registry = yield* Tool.Service
const fixture = yield* setup
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
fixture.results = []
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -268,20 +206,21 @@ describe("WebSearchTool registration", () => {
it.effect("asks once and uses the default provider when web search is first enabled", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = yield* Tool.Service
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = fixture.registry
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(selection).toBe("random")
expect(queries).toHaveLength(2)
expect(formRequests).toEqual([
const first = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
})
expect(first.status).toBe("completed")
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.formRequests).toEqual([
{
sessionID,
title: "Web Search",
@@ -309,26 +248,27 @@ describe("WebSearchTool registration", () => {
},
])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(formRequests).toHaveLength(1)
expect(queries).toHaveLength(3)
const second = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
})
expect(second.status).toBe("completed")
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
expect(fixture.formRequests).toHaveLength(1)
expect(fixture.websearch.queries).toHaveLength(3)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
providerRequired = true
formResponses.push(
const fixture = yield* setup
fixture.formResponses.push(
{ status: "answered", answer: { choice: "choose" } },
{ status: "answered", answer: { provider: "parallel" } },
)
const registry = yield* Tool.Service
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -337,9 +277,10 @@ describe("WebSearchTool registration", () => {
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
expect(selection).toBe(WebSearch.ID.make("parallel"))
expect(queries).toHaveLength(2)
expect(formRequests[1]).toEqual({
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(WebSearch.ID.make("parallel"))
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.websearch.queries[1]?.providerID).toBe(WebSearch.ID.make("parallel"))
expect(fixture.formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
@@ -362,10 +303,10 @@ describe("WebSearchTool registration", () => {
it.effect("shares provider consent across concurrent searches", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
queryBarrier = yield* Deferred.make<void>()
const registry = yield* Tool.Service
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
fixture.formWait = fixture.websearch.wait(5)
const registry = fixture.registry
const results = yield* Effect.all(
Array.from({ length: 5 }, (_, index) =>
@@ -384,16 +325,16 @@ describe("WebSearchTool registration", () => {
)
expect(results.every((item) => item.status === "completed")).toBe(true)
expect(formRequests).toHaveLength(1)
expect(selection).toBe("random")
expect(fixture.formRequests).toHaveLength(1)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = yield* Tool.Service
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = fixture.registry
expect(
yield* executeTool(registry, {
@@ -402,16 +343,18 @@ describe("WebSearchTool registration", () => {
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "error" })
expect(selection).toBe(false)
expect(queries).toHaveLength(1)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(false)
expect(yield* fixture.websearch.default().pipe(Effect.flip)).toBeInstanceOf(WebSearch.DisabledError)
expect(fixture.websearch.queries).toHaveLength(1)
}),
)
it.effect("reports safe HTTP failures with the attempted provider", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const fixture = yield* setup
const registry = fixture.registry
const tools = yield* registry.snapshot()
selection = WebSearch.ID.make("exa")
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
yield* Effect.forEach(
[
@@ -422,14 +365,11 @@ describe("WebSearchTool registration", () => {
({ status, message }, index) =>
Effect.gen(function* () {
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
queryError = new WebSearch.RequestError({
providerID: WebSearch.ID.make("exa"),
cause: new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
fixture.error = new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
})
const progress: Tool.Metadata[] = []
+39 -5
View File
@@ -1,13 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Effect, Exit, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
import { TestWebSearch } from "./lib/websearch"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const it = testEffect(TestWebSearch.layer)
const register = (id: string) =>
Effect.gen(function* () {
@@ -36,11 +35,28 @@ const register = (id: string) =>
})
describe("WebSearch", () => {
it.effect("shares the normal and test interfaces without installing live providers", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const test = yield* TestWebSearch.Service
expect(websearch).toBe(test)
expect(yield* websearch.providers()).toEqual([])
expect(test.queries).toEqual([])
expect((yield* websearch.query({ query: "unconfigured" }).pipe(Effect.flip))._tag).toBe(
"WebSearch.ProviderRequired",
)
yield* test.wait(1)
expect(test.queries).toEqual([{ query: "unconfigured" }])
}),
)
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const test = yield* TestWebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
new WebSearch.Response({
@@ -57,6 +73,7 @@ describe("WebSearch", () => {
)
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
expect(parallel.calls).toEqual([{ query: "effect" }])
expect(test.queries).toEqual([{ query: "effect", providerID: parallel.providerID }, { query: "default" }])
}),
)
@@ -81,6 +98,23 @@ describe("WebSearch", () => {
}),
)
it.effect("reloads active transforms from their current source", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const source = { providerID: exa.providerID }
yield* websearch.transform((draft) => draft.default.set(source.providerID))
expect((yield* websearch.default())?.id).toBe(exa.providerID)
source.providerID = parallel.providerID
const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* websearch.default())?.id).toBe(parallel.providerID)
}),
)
it.effect("persists the selected provider in KV", () =>
Effect.gen(function* () {
const parallel = yield* register("parallel")
+20 -18
View File
@@ -7,22 +7,24 @@ type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
const make =
<R>(testLayer: Layer.Layer<R>) =>
<A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(testLayer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
export const it = { effect }
export const it = { effect: make(layer), live: make(TestConsole.layer) }
+134 -180
View File
@@ -20,6 +20,7 @@ import {
emitPromise,
generate,
GenerationError,
type Output,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
@@ -32,6 +33,21 @@ function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(sour
return emitEffect(compileContract(source))
}
async function emittedModule(output: Output) {
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const dispose = () => rm(directory, { recursive: true, force: true })
try {
// Finish each write before cleanup can run, even when a later write fails.
await Array.fromAsync(output.files, (file) => Bun.write(join(directory, file.path), file.content))
const module = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
return { module, [Symbol.asyncDispose]: dispose }
} catch (cause) {
await dispose()
throw cause
}
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
@@ -352,27 +368,21 @@ describe("HttpApiCodegen.generate", () => {
),
)
const output = emitPromise(compileContract(source))
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const methods: Array<string> = []
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
@@ -825,26 +835,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
})
test("maps an emitted no-content response to undefined", async () => {
@@ -858,20 +861,13 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
})
test("executes an emitted binary wildcard GET through fetch", async () => {
@@ -885,28 +881,21 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
} finally {
await rm(directory, { recursive: true, force: true })
}
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
@@ -923,29 +912,22 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" })).toBe(
"admitted",
)
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
})
test("serializes an opaque union payload as the direct JSON body", async () => {
@@ -962,26 +944,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
})
test("serializes explicit null query values", async () => {
@@ -995,26 +970,19 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
await client.session.list({ parentID: null })
await client.session.list({ parentID: null })
expect(request?.url).toBe("https://example.com/session?parentID=null")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(request?.url).toBe("https://example.com/session?parentID=null")
})
test("rejects with declared tagged errors and exports a type guard", async () => {
@@ -1029,22 +997,15 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(emitted.module.isMissing(error)).toBeTrue()
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
@@ -1060,42 +1021,35 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await using emitted = await emittedModule(output)
let requests = 0
let url: string | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
})
test("preserves public group and endpoint identifiers exactly", () => {
@@ -1138,7 +1092,7 @@ describe("HttpApiCodegen.generate", () => {
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.effect("keeps the strict generated-consumer fixture current", () =>
it.live("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
+7 -21
View File
@@ -5,9 +5,7 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
## Defining A Plugin
@@ -48,15 +46,12 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = description
item.description = "Reviews code for regressions"
item.mode = "subagent"
})
})
@@ -69,12 +64,8 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -90,7 +81,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
event.language = event.sdk.responses(event.model.api.id)
})
```
@@ -103,15 +94,14 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use complete executable tool values with async executors:
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add({
name: "echo",
options: { codemode: false },
tools.add("echo", {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -142,10 +132,6 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+5 -17
View File
@@ -31,9 +31,7 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
Transform hooks contribute to stateful domains:
```ts
yield *
@@ -54,12 +52,8 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -78,12 +72,10 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -125,10 +117,6 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+8 -8
View File
@@ -1,11 +1,11 @@
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
const notFound = <A, R>(effect: Effect.Effect<A, MCP.NotFoundError, R>) =>
const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
@@ -14,7 +14,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.list",
Effect.fn(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
return yield* response(
service
.servers()
@@ -29,7 +29,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.add",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* service.add(ctx.params.server, ctx.payload.config)
return HttpApiSchema.NoContent.make()
}),
@@ -37,7 +37,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.remove",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* notFound(service.remove(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
@@ -45,7 +45,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.connect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* notFound(service.connect(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
@@ -53,7 +53,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.disconnect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
const service = yield* Mcp.Service
yield* notFound(service.disconnect(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
@@ -61,7 +61,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
.handle(
"mcp.resource.catalog",
Effect.fn(function* () {
const service = yield* MCP.Service
const service = yield* Mcp.Service
return yield* response(service.resourceCatalog())
}),
)
+3 -3
View File
@@ -17,7 +17,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@@ -118,8 +118,8 @@ function makeRoutes<AuthError, AuthServices>(
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
Mcp.node,
Mcp.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
+48 -63
View File
@@ -3,75 +3,60 @@ import path from "node:path"
import { expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
import { AbsolutePath } from "@opencode-ai/schema/schema"
it.live("returns ordered config entries for the requested directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-config-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
})
const url = new URL("/api/config", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
const server = yield* startServer(global)
const url = new URL("/api/config", server.base)
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(document?.path).toBe(AbsolutePath.make(config))
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(document?.path).toBe(AbsolutePath.make(config))
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
+10 -10
View File
@@ -61,7 +61,7 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
const body: unknown = yield* Effect.promise(() => response.json())
if (typeof body !== "object" || body === null) throw new Error("Expected a health response object")
expect((body as Record<string, unknown>)["healthy"]).toBe(true)
}).pipe(Effect.scoped),
}),
)
it.live("activates credentials through the HttpApi", () =>
@@ -71,7 +71,7 @@ it.live("activates credentials through the HttpApi", () =>
handler(new Request("http://opencode.local/api/credential/cred_missing/activate", { method: "POST" })),
)
expect(response.status).toBe(204)
}).pipe(Effect.scoped),
}),
)
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
@@ -93,7 +93,7 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
),
)
expect(preflight.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
}).pipe(Effect.scoped),
}),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
@@ -113,7 +113,7 @@ it.live("cancels a stale OpenAI OAuth callback server before falling back", () =
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback")
}).pipe(Effect.scoped),
}),
)
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
@@ -133,7 +133,7 @@ it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback")
}).pipe(Effect.scoped),
}),
)
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
@@ -155,7 +155,7 @@ it.live("explains how to recover when both OpenAI OAuth callback ports are busy"
"OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.",
kind: "integration_authorization",
})
}).pipe(Effect.scoped),
}),
)
it.live("treats destroying a missing workspace as success", () =>
@@ -171,7 +171,7 @@ it.live("treats destroying a missing workspace as success", () =>
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ destroyed: false })
}).pipe(Effect.scoped),
}),
)
it.live("creates idempotent caller-identified workspaces through the HttpApi", () =>
@@ -213,7 +213,7 @@ it.live("creates idempotent caller-identified workspaces through the HttpApi", (
const minted = yield* create({ provider: "fake" })
expect(minted.status).toBe(200)
expect(yield* Effect.promise(() => minted.json())).toMatchObject({ data: expect.stringMatching(/^wrk_/) })
}).pipe(Effect.scoped),
}),
)
it.live("serves the session view operation and missing-session error", () =>
@@ -260,7 +260,7 @@ it.live("serves the session view operation and missing-session error", () =>
),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
@@ -283,5 +283,5 @@ it.live("stays serviceable when the first request aborts", () =>
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
expect(second.status).toBe(200)
}).pipe(Effect.scoped),
}),
)
+19
View File
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { ServerProcess } from "../../src/process"
export const startServer = Effect.fnUntraced(function* (directory: string) {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory },
fs: { filewatcher: false },
})
return {
base: HttpServer.formatAddress(server.address),
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}
})
+29 -33
View File
@@ -31,41 +31,37 @@ const generate = makeLocationNode({
})
it.live("uses base configuration without depending on process.cwd()", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-generate-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
]),
)
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
},
{ overrides: [[Generate.node, generate]] },
)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-generate-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
]),
)
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
},
{ overrides: [[Generate.node, generate]] },
)
expect(global).not.toBe(process.cwd())
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
model: { providerID: "base", model: "default" },
})
expect(global).not.toBe(process.cwd())
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
model: { providerID: "base", model: "default" },
})
const legacy = new URL("http://opencode.local/api/generate")
legacy.searchParams.set("location[directory]", project)
expect(yield* request(handler, legacy)).toEqual({
model: { providerID: "base", model: "default" },
})
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
const legacy = new URL("http://opencode.local/api/generate")
legacy.searchParams.set("location[directory]", project)
expect(yield* request(handler, legacy)).toEqual({
model: { providerID: "base", model: "default" },
})
}),
)
function request(handler: (request: Request) => Promise<Response>, url: URL) {
+28 -43
View File
@@ -2,54 +2,39 @@ import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
it.live("waits for plugin initialization before listing models", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
})
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* startServer(tmp.path)
const url = new URL("/api/model", server.base)
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -141,5 +141,5 @@ it.live("updates completed assistant message content through the session HTTP AP
_tag: "ConflictError",
resource: state.assistant,
})
}).pipe(Effect.scoped),
}),
)
+1 -1
View File
@@ -29,5 +29,5 @@ it.live("boots the workerd profile over durable object storage", () =>
const body: unknown = yield* Effect.promise(() => health.json())
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
}),
)
+43 -52
View File
@@ -3,67 +3,58 @@ import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
import { startServer } from "./fixture/server"
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
const location = new URL("/api/location", base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, base)
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const location = new URL("/api/location", server.base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() =>
fetch(location, { headers: server.headers }).then((response) => response.json()),
)
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, server.base)
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
expect(removed.status).toBe(204)
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -10,7 +10,9 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Start follow-up patch" }).click()
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(/^3 /)
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
@@ -17,7 +17,9 @@ for (const tool of ["shell", "execute", "subagent"]) {
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(/^2 /)
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
@@ -33,7 +35,7 @@ for (const expanded of [false, true]) {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
const trigger = expanded
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
: timeline.getByRole("button", { name: "Used Shell", exact: true })
: timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -81,7 +83,7 @@ story("transitions a streaming shell from writing through command execution", as
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Complete command" }).click()
const summary = timeline.getByRole("button", { name: "Used Shell", exact: true })
const summary = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(subtitle).toHaveText("printf ready")
@@ -128,7 +130,7 @@ for (const open of [false, true]) {
await expect(thought).not.toContainText("Inspecting stability")
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Finish session" }).click()
const used = group.getByRole("button", { name: "Used Shell", exact: true })
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
@@ -137,7 +139,9 @@ for (const open of [false, true]) {
"aria-expanded",
String(open),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("1 Shell")
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
@@ -183,8 +187,10 @@ for (const locale of ["de", "ar"] as const) {
await timeline.getByRole("button", { name: "Complete read" }).click()
await timeline.getByRole("button", { name: "Complete glob" }).click()
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 2 /)
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(/^2 /)
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -50,9 +50,11 @@ for (const mode of ["hidden", "compact", "full"] as const) {
if (following === "tool") {
const group = timeline.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(trigger).toContainText("UsedSkill")
await expect(trigger).toHaveText(/^Used\s*1 Skill$/)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("1 Skill")
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeHidden()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
@@ -25,7 +25,7 @@ story("space activates a focused timeline button instead of scrolling", async ({
await page.setViewportSize({ width: 800, height: 240 })
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
const trigger = timeline.getByRole("button", { name: "Used Shell", exact: true })
const trigger = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.focus()
const before = await page.evaluate(() => window.scrollY)
@@ -42,7 +42,9 @@ story("renders every tool error outcome without leaking hidden tools", async ({
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(names.length))
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(new RegExp(`^${names.length} `))
await group.getByRole("button").click()
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
@@ -68,7 +70,7 @@ story("transitions shell and question through running error outcomes", async ({
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels all web search provider variants", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
await timeline.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
await timeline.getByRole("button", { name: "Used 3 Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = timeline.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
@@ -101,8 +103,10 @@ story("labels read tools from their path input", async ({ mount }) => {
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skill")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("2 Skill")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
@@ -124,8 +128,10 @@ story("groups every collapsed tool until visible text separates the stack", asyn
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.getByRole("button")).toHaveAccessibleName("Used 4 Glob, Grep, Shell, List")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("4 Glob, Grep, Shell, List")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
@@ -6,12 +6,14 @@ for (const open of [true, false]) {
async ({ mount }, info) => {
const root = await mount("current-session-file-changes--appending-tool-calls")
const group = root.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
const trigger = group.getByRole("button", { name: /^Used \d+ Shell, Patch$/ })
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
await trigger.click()
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
})
const patch = group.locator('[data-disclosure-probe="existing"]')
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
})
const patch = group.locator('[data-disclosure-probe="existing"]')
const first = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
const second = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "b.ts" })
const diff = patch.locator('[data-type="update"]').filter({ hasText: "b.ts" }).locator('[data-component="file"]')
@@ -29,7 +31,10 @@ for (const open of [true, false]) {
const original = await patch.elementHandle()
for (const count of [3, 4]) {
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(`${count} Shell, Patch`)
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
await expect(diff).toBeVisible()
await root
.locator('[data-component="session-timeline"]')
@@ -38,10 +43,7 @@ for (const open of [true, false]) {
await expect(first).toHaveAttribute("aria-expanded", String(open))
await expect(second).toHaveAttribute("aria-expanded", "true")
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
await expect(group.getByRole("button", { name: "Used Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
},
)
@@ -6,11 +6,14 @@ for (const reasoningDefaultOpen of [false, true]) {
async ({ mount }) => {
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
const group = root.locator('[data-component="collapsed-tool-group"]')
const used = group.getByRole("button", { name: "Used Read, Skill", exact: true })
const used = group.getByRole("button", { name: /^Used \d+ Read, Skill$/ })
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(used).toHaveAccessibleName("Used 4 Read, Skill")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("4 Read, Skill")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
/Read.*group\.ts/,
/Thought/,
@@ -31,7 +34,10 @@ for (const reasoningDefaultOpen of [false, true]) {
)
await first.getByRole("button", { name: "Thought", exact: true }).click()
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
await expect(group.locator('[data-component="tag"]')).toHaveText("5")
await expect(used).toHaveAccessibleName("Used 5 Read, Skill")
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("5 Read, Skill")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
/Read.*group\.ts/,
/Thought/,
@@ -64,8 +70,10 @@ for (const reasoningDefaultOpen of [false, true]) {
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.getByRole("button", { name: "Used Shell, Read, Agent", exact: true })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("4 Shell, Read, Agent")
await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"])
})
@@ -74,6 +82,23 @@ for (const width of [840, 390]) {
await page.setViewportSize({ width, height: 600 })
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
const trigger = group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })
const header = group.locator('[data-component="context-tool-group-trigger"]')
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Read, Agent")
await expect(header.locator('[data-component="tag"]')).toHaveCount(0)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
for (const action of ["click", "Enter", "Space"] as const) {
if (action === "click") await trigger.click()
if (action !== "click") await trigger.press(action)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(group.locator('[data-component="context-tool-group-list"]')).toBeHidden()
await expect(trigger).toBeFocused()
if (action === "click") await trigger.click()
if (action !== "click") await trigger.press(action)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-component="context-tool-group-list"]')).toBeVisible()
await expect(trigger).toBeFocused()
}
const cards = group.locator('[data-component="task-tool-surface"]')
await expect(cards).toHaveCount(2)
await expect
@@ -702,12 +702,6 @@
white-space: nowrap;
}
[data-component="tag"] {
flex-shrink: 0;
border: 0;
background: var(--v2-background-bg-layer-03);
}
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
cursor: pointer;
@@ -20,7 +20,6 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
import { BasicTool, GenericTool } from "../components/basic-tool"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Badge } from "@opencode-ai/ui/badge"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
@@ -503,10 +502,10 @@ export function CurrentContextToolGroup(props: {
].join(", "),
)
const label = createMemo(() => {
const tools = names()
const text = i18n.t("ui.messagePart.tools.used", { tools })
const index = text.indexOf(tools)
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
const title = `${tools().length} ${names()}`
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
const index = text.indexOf(title)
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
})
const items = createMemo(() =>
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
@@ -564,11 +563,10 @@ export function CurrentContextToolGroup(props: {
<Show when={label().before}>
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
</Show>
<span data-slot="basic-tool-tool-title">{names()}</span>
<span data-slot="basic-tool-tool-title">{label().title}</span>
<Show when={label().after}>
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
</Show>
<Badge>{tools().length}</Badge>
</span>
</div>
}
+4 -4
View File
@@ -123,8 +123,8 @@ export const DEFAULT_THEME = {
},
status: {
running: "$hue.interactive.800",
question: "$text.feedback.info.default",
permission: "$text.feedback.warning.default",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.800",
},
feedback: {
@@ -344,8 +344,8 @@ export const DEFAULT_THEME = {
},
status: {
running: "$hue.interactive.200",
question: "$text.feedback.info.default",
permission: "$text.feedback.warning.default",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.200",
},
feedback: {
+23 -4
View File
@@ -11,13 +11,33 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
const theme = resolveThemeDocument(document, mode)
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.text.status.question.equals(theme.text.feedback.info.default)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.feedback.warning.default)).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
}
})
test.each(["light", "dark"] as const)("custom %s themes inherit the unread attention color", (mode) => {
for (const standalone of [false, true]) {
const theme = resolveThemeDocument(
Schema.decodeUnknownSync(ThemeDocument)({
version: 2,
standalone,
[mode]: {
hue: { ...DEFAULT_THEME[mode].hue, accent: "$hue.purple" },
text: { status: { unread: "#abcdef" } },
},
}),
mode,
)
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
}
})
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
for (const standalone of [false, true]) {
const theme = resolveThemeDocument(
@@ -27,8 +47,7 @@ test.each(["light", "dark"] as const)("custom %s themes inherit and override sta
[mode]: {
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
text: {
feedback: { warning: { default: "#654321" } },
status: { question: "#123456" },
status: { question: "#123456", permission: "#654321" },
},
},
}),
@@ -124,6 +124,12 @@ for (const orientation of ["horizontal", "vertical"] as const) {
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
await app.renderOnce()
const indicatorColor = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
const glow = () => {
const colors = app
.captureSpans()
@@ -140,6 +146,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
expect(full).toBeGreaterThan(0)
setActive("first")
await app.renderOnce()
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
+1 -1
View File
@@ -34,7 +34,7 @@ const icons = {
},
"circle-exclamation": {
viewBox: "0 0 16 16",
body: `<path d="M7.9987 5.50016V8.50016M7.9987 10.5002V10.5068M14.1654 8.00016C14.1654 11.4059 11.4045 14.1668 7.9987 14.1668C6.29582 14.1668 4.75415 13.4766 3.63821 12.3607C2.52226 11.2447 1.83203 9.70304 1.83203 8.00016C1.83203 4.59441 4.59294 1.8335 7.9987 1.8335C9.70158 1.8335 11.2432 2.52372 12.3592 3.63967C13.4751 4.75562 14.1654 6.29728 14.1654 8.00016Z" stroke="currentColor" stroke-linecap="square"/>`,
body: `<path d="M8.75 11.75H7.25V10.25H8.75V11.75Z" fill="currentColor"/><path d="M8.75 9.25H7.25V4.25H8.75V9.25Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.93286 1 11.684 1.7836 12.9502 3.0498C14.2164 4.31601 15 6.06714 15 8C15 11.866 11.866 15 8 15C6.06714 15 4.31601 14.2164 3.0498 12.9502C1.7836 11.684 1 9.93286 1 8C1 4.13401 4.13401 1 8 1ZM8 2C4.68629 2 2 4.68629 2 8C2 9.65699 2.67148 11.1559 3.75781 12.2422C4.84415 13.3285 6.34301 14 8 14C11.3137 14 14 11.3137 14 8C14 6.34301 13.3285 4.84415 12.2422 3.75781C11.1559 2.67148 9.65699 2 8 2Z" fill="currentColor"/>`,
},
"sidebar-right": {
viewBox: "0 0 20 20",