mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:51:21 -04:00
refactor(core): use shared state for tool registry (#45414)
This commit is contained in:
@@ -358,14 +358,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
tools
|
||||
.transform((draft) =>
|
||||
callback({
|
||||
add: (tool) => draft.add(tool),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.as({ dispose: Effect.void })),
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
+101
-136
@@ -4,7 +4,7 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
|
||||
|
||||
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Context, Effect, Layer, Schema, SchemaIssue, Scope, Semaphore } from "effect"
|
||||
import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Agent } from "./agent.js"
|
||||
import { CodeModeCatalog } from "./codemode/catalog.js"
|
||||
@@ -14,6 +14,7 @@ import { Permission } from "./permission.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { State } from "./state.js"
|
||||
import { definition, execute, normalizeContent } from "./tool/runtime.js"
|
||||
import { Wildcard } from "./util/wildcard.js"
|
||||
|
||||
@@ -22,10 +23,16 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: (
|
||||
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
export interface Draft {
|
||||
readonly add: (tool: Tool.Info) => void
|
||||
}
|
||||
|
||||
type Data = {
|
||||
tools: Map<string, Tool.Info>
|
||||
errors: { tool: Tool.Info; error: RegistrationError }[]
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -79,9 +86,6 @@ const layer = Layer.effect(
|
||||
]
|
||||
})
|
||||
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly tool: Tool.Info }>>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const executeTool = Effect.fn("Tool.execute")(function* (
|
||||
tool: Tool.Info,
|
||||
name: string,
|
||||
@@ -137,112 +141,84 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
|
||||
const tools: Array<Tool.Info> = []
|
||||
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
|
||||
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
|
||||
yield* validateName(normalizedName(entry.tool))
|
||||
if (entry.tool.options?.codemode === false && entry.key === "execute")
|
||||
return yield* new RegistrationError({
|
||||
name: entry.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
})
|
||||
yield* Effect.try({
|
||||
try: () => ToolDefinition.make(definition(entry.tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({
|
||||
name: entry.key,
|
||||
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
|
||||
}),
|
||||
})
|
||||
return true
|
||||
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
|
||||
)
|
||||
// Reject every ambiguous entry rather than choosing a winner.
|
||||
const entries = yield* Effect.filter(valid, (entry) => {
|
||||
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
|
||||
return skipRegistration(
|
||||
entry.tool,
|
||||
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
|
||||
)
|
||||
})
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
lock.withPermit(
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(entry.key, remaining)
|
||||
else local.delete(entry.key)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
tools: new Map(),
|
||||
errors: [],
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
add: (tool) => {
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
draft.tools.set(effectiveName(tool), { ...tool, options: tool.options && { ...tool.options } })
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const tool = entries.at(-1)?.tool
|
||||
if (!tool) continue
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.sync(() => {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
@@ -260,27 +236,22 @@ function schemaMakeError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(false))
|
||||
|
||||
const validateName = (name: string) =>
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(name)
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
const validateNamespace = (namespace: string) =>
|
||||
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new RegistrationError({
|
||||
name: namespace,
|
||||
message: `Invalid tool namespace: ${JSON.stringify(namespace)}`,
|
||||
}),
|
||||
)
|
||||
function registrationError(tool: Tool.Info) {
|
||||
const namespace = tool.options?.namespace
|
||||
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
|
||||
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
|
||||
const name = normalizedName(tool)
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
|
||||
const id = effectiveName(tool)
|
||||
if (tool.options?.codemode === false && id === "execute")
|
||||
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
|
||||
const result = Result.try({
|
||||
try: () => ToolDefinition.make(definition(tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }),
|
||||
})
|
||||
return Result.isFailure(result) ? result.failure : undefined
|
||||
}
|
||||
|
||||
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
@@ -289,12 +260,6 @@ const effectiveName = (tool: Tool.Info) =>
|
||||
? normalizedName(tool)
|
||||
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
|
||||
|
||||
const normalizedEntries = (tools: ReadonlyArray<Tool.Info>) =>
|
||||
tools.map((tool) => ({
|
||||
key: effectiveName(tool),
|
||||
tool,
|
||||
}))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -30,17 +30,19 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the add-only draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
|
||||
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
|
||||
- The latest valid active registration for the same effective name wins.
|
||||
- Disposing a registration or closing its scope removes only its transform and rebuilds from the remaining transforms, revealing any earlier definition it overrode.
|
||||
- Each model request captures the effective definitions and executors it advertises; later reloads and disposal affect later snapshots. Captured executors may still reference mutable producer-owned state.
|
||||
|
||||
MCP owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides.
|
||||
|
||||
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
|
||||
|
||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -56,4 +58,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- Future Session-scoped registrations still need an explicit canonical registration design.
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -30,18 +30,15 @@ export const layer = Layer.effect(
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let current: Scope.Closeable | undefined
|
||||
let discovered: MCP.Tool[] = []
|
||||
|
||||
// Register the current tool set under a fresh child scope, then close the previous one so the
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const discovered = yield* mcp.tools()
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools
|
||||
.transform((draft) => {
|
||||
// Register once after initial discovery; only subsequent updates need a debounced reload.
|
||||
const initial = yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.transform((draft) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
draft.add({
|
||||
@@ -115,15 +112,19 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
})
|
||||
.pipe(Scope.provide(next))
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.forkScoped)
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.reload()
|
||||
}),
|
||||
)
|
||||
|
||||
const initial = yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
// Each read loads the whole catalog, so queued notifications need only one refresh.
|
||||
Stream.runForEachArray(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
|
||||
|
||||
@@ -64,10 +64,8 @@ export const registerToolPlugin = <R>(
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
tools
|
||||
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
|
||||
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
@@ -1281,12 +1282,13 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves overrides through catalog updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = (server: string, name: string) =>
|
||||
const tool = (server: string, name: string, description = name) =>
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make(server),
|
||||
name,
|
||||
description,
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
@@ -1304,6 +1306,16 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
const override = yield* registry.transform((draft) => {
|
||||
draft.add({
|
||||
name: "search",
|
||||
options: { namespace: "demo", codemode: false },
|
||||
description: "Override search",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "override" }),
|
||||
})
|
||||
})
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
@@ -1314,15 +1326,32 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
||||
"Override search",
|
||||
)
|
||||
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
|
||||
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
|
||||
}).pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() =>
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
output: name === "demo_search" ? "override" : "healthy",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
|
||||
yield* Ref.set(catalog, [
|
||||
tool("demo", "status"),
|
||||
tool("other", "lookup"),
|
||||
tool("demo", "added"),
|
||||
tool("repaired", "lookup"),
|
||||
])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_status")
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
@@ -1333,6 +1362,33 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
|
||||
"repaired_lookup",
|
||||
"execute",
|
||||
])
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
||||
"Override search",
|
||||
)
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "search", "Latest search"), tool("demo", "refreshed")])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_refreshed")
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
||||
"Override search",
|
||||
)
|
||||
|
||||
yield* override.dispose
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_refreshed",
|
||||
"demo_search",
|
||||
"execute",
|
||||
])
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
||||
"Latest search",
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_restored_search", name: "demo_search", input: {} },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", output: "healthy" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.fresh(
|
||||
@@ -1361,6 +1417,46 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after initial registration", () => {
|
||||
let reads = 0
|
||||
return Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* registration.flush
|
||||
expect(reads).toBe(1)
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
|
||||
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* TestClock.adjust("250 millis")
|
||||
yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" }))
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
expect(reads).toBe(3)
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () =>
|
||||
Effect.sync(() => [
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
name: `read_${++reads}`,
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
@@ -120,6 +120,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
|
||||
@@ -72,6 +72,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
},
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.after") {
|
||||
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
|
||||
|
||||
@@ -634,6 +634,66 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reloads and disposes Promise tools while preserving older snapshots", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const source = { description: "Original", replays: 0 }
|
||||
const registrations: Array<{ reload: () => Promise<void>; dispose: () => Promise<void> }> = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-tool-lifecycle",
|
||||
setup: async (ctx) => {
|
||||
expect(Object.keys(ctx.tool).sort()).toEqual(["hook", "reload", "transform"])
|
||||
const registration = await ctx.tool.transform((draft) => {
|
||||
source.replays++
|
||||
const description = source.description
|
||||
draft.add({
|
||||
name: "reloadable",
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
options: { codemode: false },
|
||||
execute: async () => ({ output: description }),
|
||||
})
|
||||
})
|
||||
registrations.push({ reload: ctx.tool.reload, dispose: registration.dispose })
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const registration = registrations[0]
|
||||
if (!registration) return yield* Effect.die("Promise tool registration was not captured")
|
||||
const original = yield* registry.snapshot()
|
||||
const execute = (snapshot: Tool.Snapshot) =>
|
||||
snapshot.execute({
|
||||
sessionID: Session.ID.make("ses_promise_tool_reload"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_tool_reload"),
|
||||
call: { type: "tool-call", id: "call_promise_tool_reload", name: "reloadable", input: {} },
|
||||
})
|
||||
|
||||
source.description = "Reloaded"
|
||||
yield* Effect.promise(() => registration.reload())
|
||||
const reloaded = yield* registry.snapshot()
|
||||
expect(source.replays).toBe(2)
|
||||
expect(reloaded.definitions).toContainEqual(
|
||||
expect.objectContaining({ name: "reloadable", description: "Reloaded" }),
|
||||
)
|
||||
expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" })
|
||||
expect(yield* execute(original)).toMatchObject({ output: "Original" })
|
||||
|
||||
yield* Effect.promise(() => registration.dispose())
|
||||
yield* Effect.promise(() => registration.dispose())
|
||||
expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false)
|
||||
expect(yield* execute(original)).toMatchObject({ output: "Original" })
|
||||
expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" })
|
||||
yield* Effect.promise(() => registration.reload())
|
||||
expect(source.replays).toBe(2)
|
||||
expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns content-only plugin results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -6,11 +6,13 @@ import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -71,6 +73,118 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
|
||||
)
|
||||
|
||||
describe("Tool", () => {
|
||||
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let source: Info[] = []
|
||||
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
|
||||
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
|
||||
source = [tool]
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(first)
|
||||
const advertised = yield* service.snapshot()
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
|
||||
tool.execute = constant("second").execute
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(second)
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
|
||||
source = []
|
||||
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(removed)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes overlays once and replays remaining transforms in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const runs: string[] = []
|
||||
yield* service.transform((draft) => {
|
||||
runs.push("base")
|
||||
draft.add({ ...constant("base"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
const overlay = yield* service
|
||||
.transform((draft) => {
|
||||
runs.push("overlay")
|
||||
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(runs).toEqual(["base", "base", "overlay"])
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
|
||||
|
||||
yield* overlay.dispose
|
||||
expect(runs).toEqual(["base", "base", "overlay", "base"])
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "base" })
|
||||
yield* overlay.dispose
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(runs).toEqual(["base", "base", "overlay", "base"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const runs: string[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
runs.push("base")
|
||||
draft.add({ ...constant("base"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
yield* service.transform((draft) => {
|
||||
runs.push("overlay")
|
||||
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
expect(runs).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}).pipe(Scope.provide(scope)),
|
||||
)
|
||||
|
||||
expect(runs).toEqual(["base", "overlay"])
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
expect(runs).toEqual(["base", "overlay"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last valid addition on replay and restores earlier transforms on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo_tool: constant("base") }, { codemode: false })
|
||||
let source = [{ ...constant("overlay"), name: "echo.tool", options: { codemode: false } }]
|
||||
const registration = yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "overlay" })
|
||||
|
||||
source = [...source, { ...constant("collision"), name: "echo_tool", options: { codemode: false } }]
|
||||
const collision = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(collision)
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "collision" })
|
||||
|
||||
yield* registration.dispose
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
|
||||
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
|
||||
|
||||
source = [{ ...constant("invalid"), name: "", options: { codemode: false } }]
|
||||
const invalid = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(invalid)
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs and skips invalid dotted namespaces", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
@@ -92,7 +206,7 @@ describe("Tool", () => {
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
|
||||
it.effect("skips invalid and reserved names while letting the last normalized name win", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(
|
||||
@@ -101,18 +215,18 @@ describe("Tool", () => {
|
||||
before: make(),
|
||||
"": make(),
|
||||
["x".repeat(65)]: make(),
|
||||
"echo.tool": make(),
|
||||
echo_tool: make(),
|
||||
"echo.tool": constant("first"),
|
||||
echo_tool: constant("last"),
|
||||
execute: make(),
|
||||
after: make(),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "echo_tool", "execute"])
|
||||
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
|
||||
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
|
||||
expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool")
|
||||
expect((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" })
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolDraft {
|
||||
@@ -48,5 +48,6 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
readonly hook: Hooks<ToolHooks, ToolFailures>
|
||||
}
|
||||
|
||||
@@ -291,6 +291,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
scan: (options) => run(host.storage.scan(options)),
|
||||
},
|
||||
tool: {
|
||||
reload: () => run(host.tool.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.tool.transform((draft) =>
|
||||
|
||||
@@ -59,5 +59,6 @@ interface ToolHooks {
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
readonly hook: Hooks<ToolHooks>
|
||||
}
|
||||
|
||||
@@ -829,6 +829,10 @@ interface StorageDomain {
|
||||
Register typed tools with Effect `Schema`. The executor receives decoded input and returns an Effect containing typed
|
||||
output, display content, or metadata.
|
||||
|
||||
The draft is add-only and the transform callback is synchronous: it must not return an Effect or Promise. Load
|
||||
external data before registering or reloading. OpenCode replays active transforms in registration order on a fresh
|
||||
draft; for the same effective tool name, a later valid registration overrides an earlier one.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -850,6 +854,17 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
Call `yield* ctx.tool.reload()` after changing source data captured by the callback. Reload replays active transforms
|
||||
without changing their order; it does not rerun the plugin effect.
|
||||
|
||||
`transform` returns a scoped registration. Run `yield* registration.dispose` to remove its transform and rebuild
|
||||
from the remaining transforms, revealing any earlier definition it overrode. Disposal is idempotent, and closing
|
||||
the plugin scope also disposes its registrations.
|
||||
|
||||
Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or
|
||||
executors already captured by an existing request. Executors that close over mutable plugin data still observe
|
||||
that data; capture a value inside the transform when it must remain tied to that definition.
|
||||
|
||||
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
|
||||
[`Tool.FileContent`](/api#schema-Tool.FileContent).
|
||||
|
||||
@@ -862,6 +877,7 @@ interface ToolDraft {
|
||||
|
||||
interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -784,10 +784,12 @@ interface StorageScanResult {
|
||||
|
||||
### Tools
|
||||
|
||||
Register tools with a transform.
|
||||
Register tools with an add-only transform. The callback is synchronous, including in Promise plugins; load external
|
||||
data before registering or reloading. OpenCode replays active transforms in registration order on a fresh draft.
|
||||
For the same effective tool name, a later valid registration overrides an earlier one.
|
||||
|
||||
```ts
|
||||
await ctx.tool.transform((draft) => {
|
||||
const registration = await ctx.tool.transform((draft) => {
|
||||
draft.add({
|
||||
name: "greeting",
|
||||
description: "Create a greeting",
|
||||
@@ -806,6 +808,24 @@ await ctx.tool.transform((draft) => {
|
||||
})
|
||||
```
|
||||
|
||||
Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without
|
||||
changing their order; it does not rerun plugin setup.
|
||||
|
||||
```ts
|
||||
await ctx.tool.reload()
|
||||
```
|
||||
|
||||
Dispose a registration to remove its transform and rebuild from the remaining transforms, revealing any earlier
|
||||
definition it overrode. Disposal is idempotent, and unloading the plugin also disposes its registrations.
|
||||
|
||||
```ts
|
||||
await registration.dispose()
|
||||
```
|
||||
|
||||
Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or
|
||||
executors already captured by an existing request. Executors that close over mutable plugin data still observe
|
||||
that data; capture a value inside the transform when it must remain tied to that definition.
|
||||
|
||||
#### Reference
|
||||
|
||||
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
|
||||
@@ -814,6 +834,7 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s
|
||||
```ts
|
||||
interface ToolContext {
|
||||
transform(callback: (draft: ToolDraft) => void): Promise<Registration>
|
||||
reload(): Promise<void>
|
||||
}
|
||||
|
||||
interface ToolDraft {
|
||||
|
||||
Reference in New Issue
Block a user