mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b493af15 |
@@ -0,0 +1,127 @@
|
|||||||
|
export * as CodeModeV2 from "./code-mode"
|
||||||
|
|
||||||
|
import type { CodeMode } from "@opencode-ai/plugin/v2/effect"
|
||||||
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
|
import { Flag } from "./flag/flag"
|
||||||
|
import { PermissionV2 } from "./permission"
|
||||||
|
import { ExecuteTool } from "./code-mode/execute"
|
||||||
|
import { permission, RegistrationError, type AnyTool } from "./tool/tool"
|
||||||
|
import { Wildcard } from "./util/wildcard"
|
||||||
|
|
||||||
|
type Registration = {
|
||||||
|
readonly identity: object
|
||||||
|
readonly tool: AnyTool
|
||||||
|
readonly name: string
|
||||||
|
readonly path: readonly [string, ...string[]]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterializeInput {
|
||||||
|
readonly permissions?: PermissionV2.Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly enabled: boolean
|
||||||
|
readonly register: (
|
||||||
|
source: (draft: CodeMode.Draft) => void,
|
||||||
|
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||||
|
readonly materialize: (input: MaterializeInput) => Effect.Effect<AnyTool | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const enabled = Flag.CODEMODE_ENABLED
|
||||||
|
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
enabled,
|
||||||
|
register: Effect.fn("CodeMode.register")(function* (source) {
|
||||||
|
const pending: Array<{ readonly path: readonly [string, ...string[]]; readonly tool: AnyTool }> = []
|
||||||
|
yield* Effect.sync(() =>
|
||||||
|
source({
|
||||||
|
add: (path, tool) => pending.push({ path, tool }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (pending.length === 0) return
|
||||||
|
|
||||||
|
const entries = pending.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
key: entry.path.join("\0"),
|
||||||
|
name: entry.path.join("_"),
|
||||||
|
}))
|
||||||
|
const invalid = entries.find((entry) => entry.path.some((segment) => segment.length === 0))
|
||||||
|
if (invalid)
|
||||||
|
return yield* new RegistrationError({
|
||||||
|
name: invalid.path.join("."),
|
||||||
|
message: "Code Mode paths cannot contain empty segments",
|
||||||
|
})
|
||||||
|
const keys = new Set<string>()
|
||||||
|
const duplicate = entries.find((entry) => {
|
||||||
|
if (keys.has(entry.key)) return true
|
||||||
|
keys.add(entry.key)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (duplicate)
|
||||||
|
return yield* new RegistrationError({
|
||||||
|
name: duplicate.path.join("."),
|
||||||
|
message: `Duplicate Code Mode path: ${duplicate.path.join(".")}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const token = {}
|
||||||
|
for (const entry of entries)
|
||||||
|
local.set(entry.key, [
|
||||||
|
...(local.get(entry.key) ?? []),
|
||||||
|
{
|
||||||
|
token,
|
||||||
|
registration: {
|
||||||
|
identity: {},
|
||||||
|
tool: entry.tool,
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||||
|
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||||
|
else local.delete(entry.key)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
materialize: Effect.fn("CodeMode.materialize")(function* (input) {
|
||||||
|
if (!enabled || whollyDisabled("execute", input.permissions ?? [])) return undefined
|
||||||
|
const registrations = new Map<string, Registration>()
|
||||||
|
for (const [key, entries] of local) {
|
||||||
|
const registration = entries.at(-1)?.registration
|
||||||
|
if (
|
||||||
|
registration &&
|
||||||
|
!whollyDisabled(permission(registration.tool, registration.name), input.permissions ?? [])
|
||||||
|
)
|
||||||
|
registrations.set(key, registration)
|
||||||
|
}
|
||||||
|
if (registrations.size === 0) return undefined
|
||||||
|
return ExecuteTool.create({
|
||||||
|
registrations,
|
||||||
|
current: (key) => local.get(key)?.at(-1)?.registration,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||||
|
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
||||||
|
return rule?.resource === "*" && rule.effect === "deny"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
@@ -3,7 +3,7 @@ export * as ExecuteTool from "./execute"
|
|||||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||||
import { ToolOutput } from "@opencode-ai/llm"
|
import { ToolOutput } from "@opencode-ai/llm"
|
||||||
import { Effect, Ref, Schema } from "effect"
|
import { Effect, Ref, Schema } from "effect"
|
||||||
import { definition, make, settle, type AnyTool } from "./tool"
|
import { definition, make, settle, type AnyTool } from "../tool/tool"
|
||||||
|
|
||||||
const ExecuteFile = Schema.Struct({
|
const ExecuteFile = Schema.Struct({
|
||||||
data: Schema.String,
|
data: Schema.String,
|
||||||
@@ -40,7 +40,11 @@ export interface Registration {
|
|||||||
readonly identity: object
|
readonly identity: object
|
||||||
readonly tool: AnyTool
|
readonly tool: AnyTool
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly group?: string
|
readonly path: readonly [string, ...string[]]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeModeTools {
|
||||||
|
[name: string]: Tool.Definition<never> | CodeModeTools
|
||||||
}
|
}
|
||||||
|
|
||||||
export const create = (options: {
|
export const create = (options: {
|
||||||
@@ -51,33 +55,16 @@ export const create = (options: {
|
|||||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||||
hooks?: CodeMode.ToolCallHooks,
|
hooks?: CodeMode.ToolCallHooks,
|
||||||
) => {
|
) => {
|
||||||
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
const tools: CodeModeTools = Object.create(null)
|
||||||
for (const [name, registration] of options.registrations) {
|
for (const [key, registration] of options.registrations) {
|
||||||
const child = definition(name, registration.tool)
|
const child = definition(registration.name, registration.tool)
|
||||||
const value = Tool.make({
|
const value = Tool.make({
|
||||||
description: child.description,
|
description: child.description,
|
||||||
input: child.inputSchema,
|
input: child.inputSchema,
|
||||||
output: child.outputSchema,
|
output: child.outputSchema,
|
||||||
run: (input) => invoke(name, registration, input),
|
run: (input) => invoke(key, registration, input),
|
||||||
})
|
})
|
||||||
if (registration.group === undefined) {
|
addTool(tools, registration.path, value)
|
||||||
const path = registration.name
|
|
||||||
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
|
|
||||||
tools[path] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const path = registration.name
|
|
||||||
const namespace = registration.group
|
|
||||||
const group = tools[namespace]
|
|
||||||
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
|
|
||||||
if (group) {
|
|
||||||
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
|
|
||||||
group[path] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const entries: Record<string, Tool.Definition<never>> = {}
|
|
||||||
entries[path] = value
|
|
||||||
tools[namespace] = entries
|
|
||||||
}
|
}
|
||||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||||
}
|
}
|
||||||
@@ -112,15 +99,15 @@ export const create = (options: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
const result = yield* runtime(
|
const result = yield* runtime(
|
||||||
(name, registration, input) =>
|
(key, registration, input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||||
const current = options.current(name)
|
const current = options.current(key)
|
||||||
if (!current || current.identity !== registration.identity)
|
if (!current || current.identity !== registration.identity)
|
||||||
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
|
return yield* Effect.fail(toolError(`Stale tool call: ${registration.path.join(".")}`))
|
||||||
const output = yield* settle(
|
const output = yield* settle(
|
||||||
current.tool,
|
current.tool,
|
||||||
{ type: "tool-call", id: context.toolCallID, name, input },
|
{ type: "tool-call", id: context.toolCallID, name: registration.name, input },
|
||||||
{
|
{
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
@@ -163,6 +150,21 @@ export const create = (options: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTool(tools: CodeModeTools, path: readonly string[], value: Tool.Definition<never>) {
|
||||||
|
const [name, ...rest] = path
|
||||||
|
if (name === undefined) return
|
||||||
|
if (rest.length === 0) {
|
||||||
|
if (Object.hasOwn(tools, name)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`)
|
||||||
|
tools[name] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = tools[name]
|
||||||
|
if (Tool.isDefinition(current)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`)
|
||||||
|
const child: CodeModeTools = current ?? Object.create(null)
|
||||||
|
tools[name] = child
|
||||||
|
addTool(child, rest, value)
|
||||||
|
}
|
||||||
|
|
||||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||||
if (input === null || input === undefined) return
|
if (input === null || input === undefined) return
|
||||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||||
@@ -2,6 +2,7 @@ import { Effect, Layer, LayerMap } from "effect"
|
|||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { AISDK } from "./aisdk"
|
import { AISDK } from "./aisdk"
|
||||||
import { Catalog } from "./catalog"
|
import { Catalog } from "./catalog"
|
||||||
|
import { CodeModeV2 } from "./code-mode"
|
||||||
import { CommandV2 } from "./command"
|
import { CommandV2 } from "./command"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { LayerNode } from "./effect/layer-node"
|
||||||
@@ -119,6 +120,7 @@ const locationServiceNodes = [
|
|||||||
MCP.node,
|
MCP.node,
|
||||||
PermissionV2.node,
|
PermissionV2.node,
|
||||||
ToolOutputStore.node,
|
ToolOutputStore.node,
|
||||||
|
CodeModeV2.node,
|
||||||
ToolRegistry.node,
|
ToolRegistry.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
Image.node,
|
Image.node,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as McpGuidance from "./guidance"
|
export * as McpGuidance from "./guidance"
|
||||||
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { Flag } from "../flag/flag"
|
import { CodeModeV2 } from "../code-mode"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
@@ -15,10 +15,10 @@ const Summary = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type Summary = typeof Summary.Type
|
type Summary = typeof Summary.Type
|
||||||
|
|
||||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
const entries = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
|
||||||
servers.flatMap((server) => [
|
servers.flatMap((server) => [
|
||||||
` <server name="${server.server}">`,
|
` <server name="${server.server}">`,
|
||||||
...(Flag.CODEMODE_ENABLED
|
...(codeMode
|
||||||
? [
|
? [
|
||||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||||
]
|
]
|
||||||
@@ -27,10 +27,10 @@ const entries = (servers: ReadonlyArray<Summary>) =>
|
|||||||
" </server>",
|
" </server>",
|
||||||
])
|
])
|
||||||
|
|
||||||
const render = (servers: ReadonlyArray<Summary>) =>
|
const render = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
|
||||||
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
|
["<mcp_instructions>", ...entries(servers, codeMode), "</mcp_instructions>"].join("\n")
|
||||||
|
|
||||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>, codeMode: boolean) => {
|
||||||
const diff = Instructions.diffByKey(
|
const diff = Instructions.diffByKey(
|
||||||
previous,
|
previous,
|
||||||
current,
|
current,
|
||||||
@@ -41,12 +41,15 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
|||||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||||
return [
|
return [
|
||||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||||
render(current),
|
render(current, codeMode),
|
||||||
].join("\n")
|
].join("\n")
|
||||||
return [
|
return [
|
||||||
...(diff.added.length === 0
|
...(diff.added.length === 0
|
||||||
? []
|
? []
|
||||||
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
|
: [
|
||||||
|
"New MCP server instructions are available in addition to those previously listed:",
|
||||||
|
...entries(diff.added, codeMode),
|
||||||
|
]),
|
||||||
...(diff.removed.length === 0
|
...(diff.removed.length === 0
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -64,13 +67,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const codeMode = yield* CodeModeV2.Service
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
||||||
const agent = selection.info
|
const agent = selection.info
|
||||||
if (!agent) return Instructions.empty
|
if (!agent) return Instructions.empty
|
||||||
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
if (codeMode.enabled && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||||
return Instructions.empty
|
return Instructions.empty
|
||||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
@@ -80,7 +84,7 @@ export const layer = Layer.effect(
|
|||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const owned = tools.filter((tool) => tool.server === item.server)
|
const owned = tools.filter((tool) => tool.server === item.server)
|
||||||
return (
|
return (
|
||||||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
|
(!codeMode.enabled && owned.length === 0) ||
|
||||||
owned.some(
|
owned.some(
|
||||||
(tool) =>
|
(tool) =>
|
||||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||||
@@ -93,8 +97,8 @@ export const layer = Layer.effect(
|
|||||||
key: Instructions.Key.make("core/mcp-guidance"),
|
key: Instructions.Key.make("core/mcp-guidance"),
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
load: Effect.succeed(visible),
|
load: Effect.succeed(visible),
|
||||||
baseline: render,
|
baseline: (servers) => render(servers, codeMode.enabled),
|
||||||
update,
|
update: (previous, current) => update(previous, current, codeMode.enabled),
|
||||||
removed: () => "MCP server instructions are no longer available.",
|
removed: () => "MCP server instructions are no longer available.",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -102,4 +106,4 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [CodeModeV2.node, MCP.node] })
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
|||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { AISDK } from "./aisdk"
|
import { AISDK } from "./aisdk"
|
||||||
import { Catalog } from "./catalog"
|
import { Catalog } from "./catalog"
|
||||||
|
import { CodeModeV2 } from "./code-mode"
|
||||||
import { CommandV2 } from "./command"
|
import { CommandV2 } from "./command"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
@@ -124,6 +125,7 @@ export const node = makeLocationNode({
|
|||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
AISDK.node,
|
AISDK.node,
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
|
CodeModeV2.node,
|
||||||
CommandV2.node,
|
CommandV2.node,
|
||||||
Integration.node,
|
Integration.node,
|
||||||
Location.node,
|
Location.node,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Effect, Schema, Stream } from "effect"
|
|||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { AISDK } from "../aisdk"
|
import { AISDK } from "../aisdk"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
|
import { CodeModeV2 } from "../code-mode"
|
||||||
import { CommandV2 } from "../command"
|
import { CommandV2 } from "../command"
|
||||||
import { Credential } from "../credential"
|
import { Credential } from "../credential"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
@@ -28,6 +29,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
const codeMode = yield* CodeModeV2.Service
|
||||||
const commands = yield* CommandV2.Service
|
const commands = yield* CommandV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const integration = yield* Integration.Service
|
const integration = yield* Integration.Service
|
||||||
@@ -158,6 +160,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
callback(draft)
|
callback(draft)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
codemode: {
|
||||||
|
register: codeMode.register,
|
||||||
|
},
|
||||||
event: {
|
event: {
|
||||||
subscribe: () => events.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
subscribe: () => events.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
|||||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { Flag } from "../flag/flag"
|
import { CodeModeV2 } from "../code-mode"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
@@ -21,6 +21,7 @@ export const name = (server: string, tool: string) => `${group(server)}_${tool.r
|
|||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
|
const codeMode = yield* CodeModeV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
@@ -105,13 +106,14 @@ export const layer = Layer.effectDiscard(
|
|||||||
groups.set(tool.server, group)
|
groups.set(tool.server, group)
|
||||||
}
|
}
|
||||||
const next = yield* Scope.fork(scope)
|
const next = yield* Scope.fork(scope)
|
||||||
yield* Effect.forEach(
|
const register = codeMode.enabled
|
||||||
groups,
|
? codeMode.register((draft) => {
|
||||||
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
|
for (const [server, record] of groups)
|
||||||
{
|
for (const [tool, value] of Object.entries(record))
|
||||||
discard: true,
|
draft.add([group(server), tool.replace(/[^a-zA-Z0-9_-]/g, "_")], value)
|
||||||
},
|
})
|
||||||
).pipe(Scope.provide(next), Effect.orDie)
|
: Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), { discard: true })
|
||||||
|
yield* register.pipe(Scope.provide(next), Effect.orDie)
|
||||||
if (current) yield* Scope.close(current, Exit.void)
|
if (current) yield* Scope.close(current, Exit.void)
|
||||||
current = next
|
current = next
|
||||||
}),
|
}),
|
||||||
@@ -128,5 +130,5 @@ export const layer = Layer.effectDiscard(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
name: "mcp-tools",
|
name: "mcp-tools",
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
|
deps: [CodeModeV2.node, ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ export * as ToolRegistry from "./registry"
|
|||||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import type { AgentV2 } from "../agent"
|
import type { AgentV2 } from "../agent"
|
||||||
import { Flag } from "../flag/flag"
|
import { CodeModeV2 } from "../code-mode"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { SessionMessage } from "../session/message"
|
import { SessionMessage } from "../session/message"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { ToolOutputStore } from "../tool-output-store"
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
import { Wildcard } from "../util/wildcard"
|
import { Wildcard } from "../util/wildcard"
|
||||||
import { ExecuteTool } from "./execute"
|
|
||||||
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
import { ToolHooks } from "./hooks"
|
import { ToolHooks } from "./hooks"
|
||||||
@@ -55,14 +54,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
const registryLayer = Layer.effect(
|
const registryLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const codeMode = yield* CodeModeV2.Service
|
||||||
const resources = yield* ToolOutputStore.Service
|
const resources = yield* ToolOutputStore.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
const toolHooks = yield* ToolHooks.Service
|
||||||
type Registration = {
|
type Registration = {
|
||||||
readonly identity: object
|
readonly identity: object
|
||||||
readonly tool: AnyTool
|
readonly tool: AnyTool
|
||||||
readonly name: string
|
|
||||||
readonly group?: string
|
|
||||||
readonly deferred: boolean
|
|
||||||
}
|
}
|
||||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||||
|
|
||||||
@@ -150,7 +147,7 @@ const registryLayer = Layer.effect(
|
|||||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||||
const entries = registrationEntries(tools, options?.group)
|
const entries = registrationEntries(tools, options?.group)
|
||||||
if (entries.length === 0) return
|
if (entries.length === 0) return
|
||||||
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute")
|
const reserved = entries.find((entry) => entry.key === "execute")
|
||||||
if (reserved)
|
if (reserved)
|
||||||
return yield* Effect.fail(
|
return yield* Effect.fail(
|
||||||
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
||||||
@@ -166,9 +163,6 @@ const registryLayer = Layer.effect(
|
|||||||
registration: {
|
registration: {
|
||||||
identity: {},
|
identity: {},
|
||||||
tool: entry.tool,
|
tool: entry.tool,
|
||||||
name: entry.name,
|
|
||||||
group: entry.group,
|
|
||||||
deferred: options?.deferred ?? false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -195,30 +189,18 @@ const registryLayer = Layer.effect(
|
|||||||
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
||||||
for (const [name, registration] of registrations) {
|
for (const [name, registration] of registrations) {
|
||||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||||
if (
|
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
|
||||||
wrongEditTool ||
|
|
||||||
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
|
|
||||||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
|
||||||
)
|
|
||||||
registrations.delete(name)
|
registrations.delete(name)
|
||||||
}
|
}
|
||||||
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
const execute = yield* codeMode.materialize({ permissions: input.permissions })
|
||||||
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
|
|
||||||
const execute =
|
|
||||||
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
|
|
||||||
? ExecuteTool.create({
|
|
||||||
registrations: deferred,
|
|
||||||
current: (name) => local.get(name)?.at(-1)?.registration,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
return {
|
return {
|
||||||
definitions: [
|
definitions: [
|
||||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
...Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
|
||||||
...(execute ? [definition("execute", execute)] : []),
|
...(execute ? [definition("execute", execute)] : []),
|
||||||
],
|
],
|
||||||
settle: (input) => {
|
settle: (input) => {
|
||||||
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||||
const registration = direct.get(input.call.name)
|
const registration = registrations.get(input.call.name)
|
||||||
if (registration) return settleWith(input, registration.identity)
|
if (registration) return settleWith(input, registration.identity)
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
||||||
@@ -244,11 +226,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const toolsNode = makeLocationNode({
|
export const toolsNode = makeLocationNode({
|
||||||
service: Tools.Service,
|
service: Tools.Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe("PluginV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
it.effect("registers direct and Code Mode tools through separate plugin domains", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugins = yield* PluginV2.Service
|
const plugins = yield* PluginV2.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
@@ -205,13 +205,17 @@ describe("PluginV2", () => {
|
|||||||
const plugin = define({
|
const plugin = define({
|
||||||
id: "grouped-tools",
|
id: "grouped-tools",
|
||||||
effect: (ctx) =>
|
effect: (ctx) =>
|
||||||
ctx.tool
|
Effect.gen(function* () {
|
||||||
|
yield* ctx.tool
|
||||||
.transform((draft) => {
|
.transform((draft) => {
|
||||||
draft.add("plain", tool("Plain"))
|
draft.add("plain", tool("Plain"))
|
||||||
draft.add("look/up", tool("Lookup"), { group: "context 7" })
|
draft.add("look/up", tool("Lookup"), { group: "context 7" })
|
||||||
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
|
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie)
|
||||||
|
yield* ctx.codemode
|
||||||
|
.register((draft) => draft.add(["context_7", "search"], tool("Search")))
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([{ plugin }])
|
yield* plugins.activate([{ plugin }])
|
||||||
@@ -221,6 +225,9 @@ describe("PluginV2", () => {
|
|||||||
"context_7_look_up",
|
"context_7_look_up",
|
||||||
"execute",
|
"execute",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
yield* plugins.activate([])
|
||||||
|
expect((yield* registry.materialize({ model: testModel })).definitions).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { CodeModeV2 } from "@opencode-ai/core/code-mode"
|
||||||
import { CommandV2 } from "@opencode-ai/core/command"
|
import { CommandV2 } from "@opencode-ai/core/command"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
@@ -43,6 +44,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
AISDK.node,
|
AISDK.node,
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
|
CodeModeV2.node,
|
||||||
CommandV2.node,
|
CommandV2.node,
|
||||||
Integration.node,
|
Integration.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
transform: () => Effect.die("unused command.transform"),
|
transform: () => Effect.die("unused command.transform"),
|
||||||
reload: () => Effect.die("unused command.reload"),
|
reload: () => Effect.die("unused command.reload"),
|
||||||
},
|
},
|
||||||
|
codemode: overrides.codemode ?? {
|
||||||
|
register: () => Effect.die("unused codemode.register"),
|
||||||
|
},
|
||||||
event: overrides.event ?? {
|
event: overrides.event ?? {
|
||||||
subscribe: () => Stream.empty,
|
subscribe: () => Stream.empty,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { CodeModeV2 } from "@opencode-ai/core/code-mode"
|
||||||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
@@ -28,7 +30,9 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
|
const registryLayer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, CodeModeV2.node]), [
|
||||||
|
[ToolOutputStore.node, outputStore],
|
||||||
|
])
|
||||||
const it = testEffect(registryLayer)
|
const it = testEffect(registryLayer)
|
||||||
const identity = {
|
const identity = {
|
||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
@@ -53,6 +57,30 @@ const make = (permission?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ToolRegistry", () => {
|
describe("ToolRegistry", () => {
|
||||||
|
it.effect("materializes nested Code Mode sources as execute", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const codeMode = yield* CodeModeV2.Service
|
||||||
|
const service = yield* ToolRegistry.Service
|
||||||
|
yield* codeMode.register((draft) => draft.add(["opencode", "v2", "echo"], make()))
|
||||||
|
|
||||||
|
const definitions = yield* toolDefinitions(service)
|
||||||
|
expect(definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||||
|
expect(definitions[0]?.description).toContain("tools.opencode.v2.echo")
|
||||||
|
expect(
|
||||||
|
yield* executeTool(service, {
|
||||||
|
sessionID,
|
||||||
|
...identity,
|
||||||
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-code-mode",
|
||||||
|
name: "execute",
|
||||||
|
input: { code: 'return await tools.opencode.v2.echo({ text: "hello" })' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "text", value: '{\n "text": "hello"\n}' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Effect, Scope } from "effect"
|
||||||
|
import type { AnyTool, RegistrationError } from "./tool.js"
|
||||||
|
|
||||||
|
export type Path = readonly [string, ...string[]]
|
||||||
|
|
||||||
|
export interface Draft {
|
||||||
|
add(path: Path, tool: AnyTool): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Domain {
|
||||||
|
readonly register: (source: (draft: Draft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js"
|
|||||||
import type { AISDKHooks } from "./aisdk.js"
|
import type { AISDKHooks } from "./aisdk.js"
|
||||||
import type { CatalogHooks } from "./catalog.js"
|
import type { CatalogHooks } from "./catalog.js"
|
||||||
import type { CommandHooks } from "./command.js"
|
import type { CommandHooks } from "./command.js"
|
||||||
|
import type { Domain } from "./code-mode.js"
|
||||||
import type { EventHooks } from "./event.js"
|
import type { EventHooks } from "./event.js"
|
||||||
import type { IntegrationHooks } from "./integration.js"
|
import type { IntegrationHooks } from "./integration.js"
|
||||||
import type { PluginDomain } from "./plugin.js"
|
import type { PluginDomain } from "./plugin.js"
|
||||||
@@ -17,6 +18,7 @@ export interface PluginContext {
|
|||||||
readonly aisdk: AISDKHooks
|
readonly aisdk: AISDKHooks
|
||||||
readonly catalog: CatalogHooks
|
readonly catalog: CatalogHooks
|
||||||
readonly command: CommandHooks
|
readonly command: CommandHooks
|
||||||
|
readonly codemode: Domain
|
||||||
readonly event: EventHooks
|
readonly event: EventHooks
|
||||||
readonly integration: IntegrationHooks
|
readonly integration: IntegrationHooks
|
||||||
readonly plugin: PluginDomain
|
readonly plugin: PluginDomain
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
|
|||||||
export type { AISDKHooks } from "./aisdk.js"
|
export type { AISDKHooks } from "./aisdk.js"
|
||||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||||
|
export * as CodeMode from "./code-mode.js"
|
||||||
export type { EventHooks } from "./event.js"
|
export type { EventHooks } from "./event.js"
|
||||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
||||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||||
|
|||||||
@@ -246,7 +246,6 @@ export interface ToolExecuteAfterEvent {
|
|||||||
|
|
||||||
export interface RegisterOptions {
|
export interface RegisterOptions {
|
||||||
readonly group?: string
|
readonly group?: string
|
||||||
readonly deferred?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolDraft {
|
export interface ToolDraft {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ test.each([
|
|||||||
expect(entrypoint.Skill).toBe(Skill)
|
expect(entrypoint.Skill).toBe(Skill)
|
||||||
expect(Object.keys(entrypoint).sort()).toEqual([
|
expect(Object.keys(entrypoint).sort()).toEqual([
|
||||||
"Agent",
|
"Agent",
|
||||||
|
...(name === "effect" ? ["CodeMode"] : []),
|
||||||
"Command",
|
"Command",
|
||||||
"Connection",
|
"Connection",
|
||||||
"Credential",
|
"Credential",
|
||||||
|
|||||||
Reference in New Issue
Block a user