Merge remote-tracking branch 'origin/v2' into mcp-prompts
# Conflicts: # packages/core/src/mcp/index.ts
This commit is contained in:
commit
dd8e44ad8c
67 changed files with 1970 additions and 984 deletions
|
|
@ -40,7 +40,7 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
|
@ -104,7 +104,7 @@ export const layer = Layer.effect(
|
|||
const info = selectedDefault()
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
all: Effect.fn("AgentV2.all")(function* () {
|
||||
list: Effect.fn("AgentV2.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const layer = Layer.effect(
|
|||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
const names = ["config.json", "opencode.json", "opencode.jsonc"]
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
|
|||
|
||||
export function replace<A, E, R, E2>(
|
||||
source: Layer.Layer<A, E, R>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, NoInfer<R>> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement {
|
||||
return { source, replacement }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,9 +210,19 @@ export const layer = Layer.effect(
|
|||
const remote = entry.config
|
||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||
const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16)
|
||||
const suffix =
|
||||
"mcp_" +
|
||||
createHash("sha1")
|
||||
.update(name + "\u0000" + remote.url)
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
entry.integrationID = Integration.ID.make(suffix)
|
||||
registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) })
|
||||
registrations.push({
|
||||
name,
|
||||
remote,
|
||||
integrationID: entry.integrationID,
|
||||
methodID: Integration.MethodID.make(suffix),
|
||||
})
|
||||
}
|
||||
if (registrations.length > 0)
|
||||
yield* integration.transform((draft) => {
|
||||
|
|
@ -323,6 +333,9 @@ export const layer = Layer.effect(
|
|||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
connection.onClose(() => {
|
||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
||||
if (entry.client !== connection) return
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
|
|
@ -467,7 +480,11 @@ export const layer = Layer.effect(
|
|||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message })))
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
),
|
||||
)
|
||||
return new ToolResult({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as PluginV2 from "./plugin"
|
|||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
|
|
@ -11,17 +11,20 @@ import { CommandV2 } from "./command"
|
|||
import { EventV2 } from "./event"
|
||||
import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { Location } from "./location"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Event = Plugin.Event
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect<void>
|
||||
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly wait: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
|
@ -38,9 +41,9 @@ export const layer = Layer.effect(
|
|||
const loading = new Set<ID>()
|
||||
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
||||
const failures = new Map<ID, Exit.Exit<void, never>>()
|
||||
let host: Parameters<PluginRuntime["effect"]>[0]
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) {
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
|
|
@ -150,6 +153,8 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(ToolRegistry.defaultLayer),
|
||||
Layer.provideMerge(PluginRuntime.layer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
|
|
@ -162,7 +167,10 @@ export const node = makeLocationNode({
|
|||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ import { Catalog } from "../catalog"
|
|||
import { CommandV2 } from "../command"
|
||||
import { Credential } from "../credential"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Tool } from "../tool/tool"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
||||
|
|
@ -23,12 +28,38 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
const locationRef = (input?: Parameters<Interface["agent"]["list"]>[0]) =>
|
||||
input?.location === undefined
|
||||
? undefined
|
||||
: Location.Ref.make({
|
||||
directory: AbsolutePath.make(input.location.directory ?? location.directory),
|
||||
workspaceID:
|
||||
input.location.workspace === undefined
|
||||
? location.workspaceID
|
||||
: WorkspaceV2.ID.make(input.location.workspace),
|
||||
})
|
||||
const isCurrentLocation = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
|
||||
return {
|
||||
options: {},
|
||||
agent: {
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
||||
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
agents.transform((draft) =>
|
||||
|
|
@ -215,5 +246,21 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
}),
|
||||
),
|
||||
},
|
||||
tool: {
|
||||
register: (input) => tools.register(input as Readonly<Record<string, Tool.AnyTool>>),
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
location:
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
get: (input) => runtime.session.get(input.sessionID),
|
||||
prompt: runtime.session.prompt,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,13 +20,19 @@ import { FSUtil } from "../fs-util"
|
|||
import { Global } from "../global"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { MCP } from "../mcp"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { State } from "../state"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
|
|
@ -36,6 +42,8 @@ import { ProviderPlugins } from "./provider"
|
|||
import { SdkPlugins } from "./sdk"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
import { ShellTool } from "../tool/shell"
|
||||
import { SubagentTool } from "../tool/subagent"
|
||||
|
||||
export type Requirements =
|
||||
| AgentV2.Service
|
||||
|
|
@ -49,11 +57,16 @@ export type Requirements =
|
|||
| HttpClient.HttpClient
|
||||
| Integration.Service
|
||||
| Location.Service
|
||||
| LocationMutation.Service
|
||||
| ModelsDev.Service
|
||||
| MCP.Service
|
||||
| Npm.Service
|
||||
| PermissionV2.Service
|
||||
| PluginRuntime.Service
|
||||
| Reference.Service
|
||||
| Shell.Service
|
||||
| SkillV2.Service
|
||||
| Tools.Service
|
||||
|
||||
export interface Plugin<R = never> {
|
||||
readonly id: string
|
||||
|
|
@ -82,8 +95,13 @@ const layer = Layer.effectDiscard(
|
|||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const shell = yield* Shell.Service
|
||||
const tools = yield* Tools.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const add = <R>(input: Plugin<R>) => {
|
||||
const loaded = {
|
||||
id: input.id,
|
||||
|
|
@ -105,8 +123,13 @@ const layer = Layer.effectDiscard(
|
|||
Effect.provideService(FileSystem.Service, filesystem),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
Effect.provideService(LocationMutation.Service, mutation),
|
||||
Effect.provideService(PermissionV2.Service, permission),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, reference),
|
||||
Effect.provideService(Shell.Service, shell),
|
||||
Effect.provideService(Tools.Service, tools),
|
||||
Effect.provideService(PluginRuntime.Service, runtime),
|
||||
),
|
||||
}
|
||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||
|
|
@ -120,6 +143,8 @@ const layer = Layer.effectDiscard(
|
|||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ShellTool.Plugin)
|
||||
yield* add(SubagentTool.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(MCPCommandPlugin.Plugin)
|
||||
|
|
@ -152,6 +177,7 @@ export const node = makeLocationNode({
|
|||
AgentV2.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
MCP.node,
|
||||
Npm.node,
|
||||
|
|
@ -160,8 +186,12 @@ export const node = makeLocationNode({
|
|||
FileSystem.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
PermissionV2.node,
|
||||
SkillV2.node,
|
||||
Reference.node,
|
||||
Shell.node,
|
||||
ToolRegistry.toolsNode,
|
||||
PluginRuntime.node,
|
||||
SdkPlugins.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
117
packages/core/src/plugin/runtime.ts
Normal file
117
packages/core/src/plugin/runtime.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
export * as PluginRuntime from "./runtime"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { Job } from "../job"
|
||||
import { Location } from "../location"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { SessionV2 } from "../session"
|
||||
|
||||
export interface Interface {
|
||||
readonly session: Pick<
|
||||
SessionV2.Interface,
|
||||
"get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: AgentV2.Info[] }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginRuntime") {}
|
||||
|
||||
export interface Cell {
|
||||
runtime?: Interface
|
||||
}
|
||||
|
||||
export const makeCell = (): Cell => ({})
|
||||
|
||||
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
|
||||
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.suspend(() => {
|
||||
const runtime = cell.runtime
|
||||
if (runtime === undefined) return unavailable<A, E, R>()
|
||||
return f(runtime)
|
||||
})
|
||||
|
||||
const defaultCell = makeCell()
|
||||
|
||||
export const layerWithCell = (cell: Cell) =>
|
||||
Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
session: {
|
||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
},
|
||||
job: {
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
|
||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
||||
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
|
||||
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const providerLayerWithCell = (cell: Cell) =>
|
||||
Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const runtime = {
|
||||
session: sessions,
|
||||
job: jobs,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* agents.list(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
} satisfies Interface
|
||||
cell.runtime = runtime
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (cell.runtime === runtime) cell.runtime = undefined
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithCell(defaultCell)
|
||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
export const providerNode = makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayer,
|
||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
|
|
@ -4,6 +4,14 @@ import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
export interface Store {
|
||||
readonly plugins: Map<string, Plugin>
|
||||
}
|
||||
|
||||
export const makeStore = (): Store => ({ plugins: new Map() })
|
||||
|
||||
const defaultStore = makeStore()
|
||||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
||||
* so `PluginInternal` can add them on every Location boot through the ordinary
|
||||
|
|
@ -12,9 +20,10 @@ import { makeGlobalNode } from "../effect/app-node"
|
|||
* applies to Locations booted afterward, matching config-plugin timing;
|
||||
* embedders register at startup before creating Sessions.
|
||||
*
|
||||
* State lives in this global-node service (like `ApplicationTools`) rather than
|
||||
* module scope, so the list belongs to one embedded instance and is disposed
|
||||
* with it instead of leaking across `OpenCode.create` calls.
|
||||
* The store is shared explicitly between the SDK construction graph and the
|
||||
* embedded route graph because `LocationServiceMap` builds Location layers lazily
|
||||
* in a nested graph. Each embedded SDK creates its own store, so instances do not
|
||||
* see each other's contributions.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
|
|
@ -23,15 +32,25 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => {
|
||||
const plugins: Plugin[] = []
|
||||
return Service.of({
|
||||
register: (plugin) => Effect.sync(() => void plugins.push(plugin)),
|
||||
all: () => plugins,
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const layerWithStore = (store: Store) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.clear()
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.set(plugin.id, plugin)
|
||||
}),
|
||||
all: () => [...store.plugins.values()],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithStore(defaultStore)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ This folder owns Core's one local tool representation, process and Location regi
|
|||
|
||||
## Representations
|
||||
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type.
|
||||
- `application-tools.ts` stores process-scoped application registrations.
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
|
|
@ -29,16 +28,15 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
|||
|
||||
## Registration
|
||||
|
||||
Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
|
||||
|
||||
Both are scoped:
|
||||
Registrations are scoped:
|
||||
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Location registrations take precedence over application registrations.
|
||||
- An invocation captures the effective tool once settlement starts.
|
||||
|
||||
`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Permissions
|
||||
|
||||
|
|
@ -54,6 +52,5 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
|
|||
|
||||
## Current Gaps
|
||||
|
||||
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
export * as ApplicationTools from "./application-tools"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { State } from "../state"
|
||||
import { Tool } from "./tool"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
readonly set: (name: string, entry: Entry) => void
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly identity: object
|
||||
readonly tool: Tool.AnyTool
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
readonly entries: () => ReadonlyMap<string, Entry>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ entries: new Map() }),
|
||||
draft: (draft) => ({
|
||||
set: (name, tool) => {
|
||||
draft.entries.set(name, tool)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ApplicationTools.register")(function* (tools) {
|
||||
const entries = Tool.registrationEntries(tools)
|
||||
if (entries.length === 0) return
|
||||
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
|
||||
yield* state.transform((draft) => {
|
||||
for (const [name, entry] of registrations) draft.set(name, entry)
|
||||
})
|
||||
}),
|
||||
entries: () => state.get().entries,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
|
@ -8,7 +8,6 @@ import { SessionMessage } from "../session/message"
|
|||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
|
@ -47,14 +46,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
const registration =
|
||||
local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
|
||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
||||
if (!registration)
|
||||
return {
|
||||
result: {
|
||||
|
|
@ -108,7 +105,7 @@ const registryLayer = Layer.effect(
|
|||
)
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
|
||||
const registrations = new Map(applications.entries())
|
||||
const registrations = new Map<string, Registration>()
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (registration) registrations.set(name, registration)
|
||||
|
|
@ -143,19 +140,16 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
}
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(ToolOutputStore.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,19 +2,16 @@ export * as ShellTool from "./shell"
|
|||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema, Scope } from "effect"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Job } from "../job"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Shell } from "../shell"
|
||||
import { Tool, type Content } from "./tool"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
|
|
@ -94,21 +91,22 @@ const externalCommandDirectories = (command: string, cwd: string) => {
|
|||
return [...directories]
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
export const Plugin = {
|
||||
id: "core-shell-tool",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const fsUtil = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
callID: string,
|
||||
command: string,
|
||||
) {
|
||||
yield* jobs.wait({ id: callID }).pipe(
|
||||
yield* runtime.job.wait({ id: callID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
const state =
|
||||
result.info?.status === "completed"
|
||||
|
|
@ -125,7 +123,7 @@ export const layer = Layer.effectDiscard(
|
|||
: state === "error"
|
||||
? (result.info!.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
return sessions.synthetic({
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
})
|
||||
|
|
@ -134,7 +132,7 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
})
|
||||
|
||||
yield* tools
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
|
|
@ -154,124 +152,110 @@ export const layer = Layer.effectDiscard(
|
|||
},
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* sessions
|
||||
.get(context.sessionID)
|
||||
.pipe(Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })))
|
||||
return yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
|
||||
if (input.background === true) {
|
||||
const run = Effect.fn("ShellTool.run")(function* () {
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout")
|
||||
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return `${body}${notice}`
|
||||
}).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)))
|
||||
})
|
||||
|
||||
const info = yield* jobs.start({
|
||||
id: context.toolCallID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
if (input.background === true) {
|
||||
const run = Effect.fn("ShellTool.run")(function* () {
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
run: run(),
|
||||
})
|
||||
yield* jobs.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
return yield* Effect.gen(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
if (final.status === "timeout")
|
||||
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return `${body}${notice}`
|
||||
}).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)))
|
||||
})
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
const info = yield* runtime.job.start({
|
||||
id: context.toolCallID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
run: run(),
|
||||
})
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(parent.location)))
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
name: "shell-tool",
|
||||
layer,
|
||||
deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node, FSUtil.node],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
export * as SubagentTool from "./subagent"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema, Scope } from "effect"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Job } from "../job"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { SessionV2 } from "../session"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "subagent"
|
||||
|
|
@ -40,18 +37,17 @@ export const description = [
|
|||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
].join("\n")
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
export const Plugin = {
|
||||
id: "core-subagent-tool",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const messages = yield* runtime.session.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
|
|
@ -71,7 +67,7 @@ export const layer = Layer.effectDiscard(
|
|||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
) {
|
||||
yield* sessions.synthetic({
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
})
|
||||
|
|
@ -82,7 +78,7 @@ export const layer = Layer.effectDiscard(
|
|||
childID: SessionSchema.ID,
|
||||
description: string,
|
||||
) {
|
||||
yield* jobs.wait({ id: childID }).pipe(
|
||||
yield* runtime.job.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
|
||||
|
|
@ -96,7 +92,7 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
})
|
||||
|
||||
yield* tools
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
|
|
@ -105,12 +101,11 @@ export const layer = Layer.effectDiscard(
|
|||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* sessions
|
||||
const parent = yield* runtime.session
|
||||
.get(context.sessionID)
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
)
|
||||
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const agent = yield* agents.resolve(input.agent)
|
||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||
if (agent.mode === "primary")
|
||||
|
|
@ -118,7 +113,7 @@ export const layer = Layer.effectDiscard(
|
|||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child = yield* sessions
|
||||
const child = yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
|
|
@ -135,12 +130,12 @@ export const layer = Layer.effectDiscard(
|
|||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
||||
yield* sessions.resume(child.id)
|
||||
yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
||||
yield* runtime.session.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id)))
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
|
||||
const info = yield* jobs.start({
|
||||
const info = yield* runtime.job.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
|
|
@ -149,18 +144,18 @@ export const layer = Layer.effectDiscard(
|
|||
})
|
||||
|
||||
if (background) {
|
||||
yield* jobs.background(info.id)
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
|
||||
const result = yield* jobs
|
||||
.block({ id: child.id, sessionID: context.sessionID })
|
||||
.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
|
||||
),
|
||||
)
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
|
||||
discard: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
|
|
@ -174,13 +169,4 @@ export const layer = Layer.effectDiscard(
|
|||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above
|
||||
// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle.
|
||||
// Agent lookup is resolved through the parent Session's location when the tool executes.
|
||||
export const node = makeGlobalNode({
|
||||
name: "subagent-tool",
|
||||
layer,
|
||||
deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ describe("AgentV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
|
||||
expect(yield* agent.all()).toEqual([])
|
||||
expect(yield* agent.list()).toEqual([])
|
||||
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
|
@ -56,7 +56,7 @@ describe("AgentV2", () => {
|
|||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
|
||||
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
|
||||
expect((yield* agent.list()).map((info) => info.id)).toEqual([id])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ describe("AgentV2", () => {
|
|||
),
|
||||
)
|
||||
|
||||
const agents = yield* agent.all()
|
||||
const agents = yield* agent.list()
|
||||
expect(agents.map((item) => String(item.id)).sort()).toEqual([
|
||||
"build",
|
||||
"compaction",
|
||||
|
|
|
|||
|
|
@ -1,288 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const permission = Layer.mock(PermissionV2.Service, {
|
||||
assert: () => Effect.void,
|
||||
})
|
||||
const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(applications, registry))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_application_tool")
|
||||
const agent = AgentV2.ID.make("build")
|
||||
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
|
||||
const contextual = (contexts: Tool.Context[]) =>
|
||||
Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "text", text: output.answer },
|
||||
{ type: "file", data: "aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
})
|
||||
|
||||
describe("ApplicationTools", () => {
|
||||
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
const tool = contextual(contexts)
|
||||
expect(Object.keys(tool)).toEqual([])
|
||||
|
||||
yield* applications.register({ opaque: tool })
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "ONCE" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes narrow scoped Location registration and sanitizes names", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools: Tools.Interface = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* tools.register({ "location.tool/search": contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool_search"])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters an application tool by its name without adding execution authorization", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
|
||||
expect(
|
||||
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a scoped application tool with Session context", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([
|
||||
{ name: "application_context", description: "Read application context" },
|
||||
])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "HELLO" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
},
|
||||
output: {
|
||||
structured: { answer: "HELLO" },
|
||||
content: [
|
||||
{ type: "text", text: "HELLO" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an application tool when its registration scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a tool before settling a call produced from an earlier definition", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registrationScope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
|
||||
yield* Scope.close(registrationScope, Exit.void)
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not leak a registration into an already closed scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an interrupted application registration until its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* applications
|
||||
.register({ interrupted: contextual([]) })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Scope.provide(scope),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("captures the registered record before later State rebuilds", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registered = { stable: contextual([]) }
|
||||
yield* applications.register(registered)
|
||||
Object.assign(registered, { late: contextual([]) })
|
||||
|
||||
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const firstContexts: Tool.Context[] = []
|
||||
const secondContexts: Tool.Context[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual(firstContexts) })
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
|
||||
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
|
||||
})
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
|
||||
})
|
||||
|
||||
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the Location tool when an application tool has the same name", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const locationContexts: Tool.Context[] = []
|
||||
const applicationContexts: Tool.Context[] = []
|
||||
const location = contextual(locationContexts)
|
||||
yield* registry.register({ shared: location })
|
||||
yield* applications.register({ shared: contextual(applicationContexts) })
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
|
||||
expect(applicationContexts).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -17,6 +17,10 @@ const it = testEffect(
|
|||
Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
] satisfies PermissionV2.Ruleset
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
|
|
@ -77,8 +81,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
|
|
@ -96,8 +99,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
|
|
@ -105,8 +107,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
])
|
||||
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
|
|
@ -264,21 +265,13 @@ Use native v2 fields.`,
|
|||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
|
|
@ -203,13 +203,9 @@ describe("Config", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "config.json"),
|
||||
JSON.stringify({ $schema: "base", providers: { base: provider } }),
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
|
||||
JSON.stringify({ $schema: "base", providers: { base: provider } }),
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
|
|
@ -225,12 +221,12 @@ describe("Config", () => {
|
|||
const config = yield* Config.Service
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(3)
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
expect(documents).toHaveLength(2)
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Config.Document)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
|
||||
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
|
||||
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
|
||||
|
|
@ -239,7 +235,29 @@ describe("Config", () => {
|
|||
(yield* config.entries())
|
||||
.filter((entry) => entry.type === "document")
|
||||
.map((document) => document.info.$schema),
|
||||
).toEqual(["base", "middle", "last"])
|
||||
).toEqual(["base", "last"])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(0)
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
|
|
@ -681,7 +699,7 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid files while loading valid config values", () =>
|
||||
it.live("ignores an invalid file while loading valid config values", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
|
|
@ -690,9 +708,8 @@ describe("Config", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
|
||||
]),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
|
|
@ -761,7 +778,7 @@ describe("Config", () => {
|
|||
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
|
||||
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
|
||||
fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
|
||||
fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
|
||||
fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
|
||||
fs.writeFile(
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("ConfigExternalPlugin", () => {
|
|||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const document = path.join(import.meta.dir, "config.json")
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
|
|
@ -82,7 +82,7 @@ describe("ConfigExternalPlugin", () => {
|
|||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "config.json"),
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
|
|
@ -125,7 +125,7 @@ describe("ConfigExternalPlugin", () => {
|
|||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "config.json"),
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
"../plugin/fixtures/missing-plugin.ts",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class OtherError {
|
|||
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root)
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
|
|
@ -32,8 +32,6 @@ const b = make({ service: B, layer: bLayer, deps: [a] })
|
|||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
|
||||
|
|
@ -51,8 +49,8 @@ make({ service: C, layer: cLayer, deps: [a] })
|
|||
|
||||
const closed = build(LayerNode.group([c]))
|
||||
const closedWithError = build(LayerNode.group([dependent]))
|
||||
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
const checkClosed: Layer.Layer<C> = closed
|
||||
const checkError: Layer.Layer<B, LayerError> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
|
|
@ -64,7 +62,6 @@ LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
|
|||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
|
||||
|
||||
// @ts-expect-error Replacement must be closed
|
||||
LayerNode.replace(bLayer, bLayer)
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import fs from "fs/promises"
|
|||
import { tmpdir as osTmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
export const tmpdir = async () => {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-")))
|
||||
export const tmpdir = async (prefix = "opencode-core-test-") => {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix)))
|
||||
return {
|
||||
path: dir,
|
||||
async [Symbol.asyncDispose]() {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,22 @@ export const toolDefinitions = (
|
|||
model = testModel,
|
||||
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
|
||||
|
||||
export function waitForTool(
|
||||
registry: ToolRegistry.Interface,
|
||||
name: string,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<void, Error> {
|
||||
return Effect.gen(function* () {
|
||||
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
|
||||
if (remaining === 0) {
|
||||
yield* Effect.fail(new Error(`Timed out waiting for tool: ${name}`))
|
||||
return
|
||||
}
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
yield* waitForTool(registry, name, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
||||
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
|
|
@ -17,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { FSUtil } from "../src/fs-util"
|
||||
import { Credential } from "../src/credential"
|
||||
import { Database } from "../src/database/database"
|
||||
|
|
@ -28,14 +27,11 @@ import { Npm } from "../src/npm"
|
|||
import { Project } from "../src/project"
|
||||
import { Reference } from "../src/reference"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
|
||||
const applicationTools = ApplicationTools.layer
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||
Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer),
|
||||
locationServiceMapLayer.pipe(
|
||||
Layer.provide(applicationTools),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
|
|
@ -83,14 +79,6 @@ describe("LocationServiceMap", () => {
|
|||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* (yield* ApplicationTools.Service).register({
|
||||
application_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(blocked.path, "opencode.json"),
|
||||
|
|
@ -105,9 +93,12 @@ describe("LocationServiceMap", () => {
|
|||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "shell")
|
||||
yield* waitForTool(registry, "subagent")
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
|
||||
tools: yield* toolDefinitions(registry),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
|
|
@ -119,13 +110,14 @@ describe("LocationServiceMap", () => {
|
|||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"application_context",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
|
|
@ -134,13 +126,14 @@ describe("LocationServiceMap", () => {
|
|||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"application_context",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { testModel } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
|
@ -68,4 +71,35 @@ describe("PluginV2", () => {
|
|||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers location tools through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const plugin = define({
|
||||
id: "tool-plugin",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.register({
|
||||
plugin_tool: Tool.make({
|
||||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
|
||||
yield* plugins.remove(PluginV2.ID.make(plugin.id))
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
return {
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
transform: () => Effect.die("unused agent.transform"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
},
|
||||
|
|
@ -49,11 +50,21 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
transform: () => Effect.die("unused skill.transform"),
|
||||
reload: () => Effect.die("unused skill.reload"),
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
register: () => Effect.die("unused tool.register"),
|
||||
},
|
||||
session: overrides.session ?? {
|
||||
create: () => Effect.die("unused session.create"),
|
||||
get: () => Effect.die("unused session.get"),
|
||||
prompt: () => Effect.die("unused session.prompt"),
|
||||
interrupt: () => Effect.die("unused session.interrupt"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
return {
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
reload: agent.reload,
|
||||
transform: (callback) =>
|
||||
agent.transform((draft) =>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
|||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
|
|
@ -28,9 +27,8 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
|||
)
|
||||
},
|
||||
})
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(outputStore))
|
||||
const it = testEffect(registry)
|
||||
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||
|
|
@ -399,38 +397,6 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
integrated.effect("rejects an application call after a Location override is registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* applications.register({ echo: make() })
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* service.register({ echo: make() })
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* applications.register({ echo: make() })
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps captured execution running after registration mutation", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
|
|
@ -124,12 +123,7 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(ToolOutputStore.defaultLayer))
|
||||
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
const echo = Layer.effectDiscard(
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
|
|
@ -286,7 +280,6 @@ const it = testEffect(
|
|||
SessionStore.defaultLayer,
|
||||
client,
|
||||
permission,
|
||||
applications,
|
||||
agents,
|
||||
registry,
|
||||
echo,
|
||||
|
|
@ -582,14 +575,14 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("advertises and executes a globally attached application tool", () =>
|
||||
it.effect("advertises and executes a location registered tool", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const applicationTools = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applicationTools.register({
|
||||
application_context: Tool.make({
|
||||
yield* registry.register({
|
||||
location_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
|
|
@ -604,7 +597,7 @@ describe("SessionRunnerLLM", () => {
|
|||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }),
|
||||
LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
|
|
@ -613,13 +606,13 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("location_context")
|
||||
expect(contexts).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: expect.stringMatching(/^msg_/),
|
||||
toolCallID: "call-application",
|
||||
toolCallID: "call-location",
|
||||
},
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
|
|
@ -629,7 +622,7 @@ describe("SessionRunnerLLM", () => {
|
|||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-application",
|
||||
id: "call-location",
|
||||
state: { status: "completed", structured: { answer: "HELLO" } },
|
||||
},
|
||||
],
|
||||
|
|
@ -914,10 +907,7 @@ describe("SessionRunnerLLM", () => {
|
|||
response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
"Build agent instructions",
|
||||
"Initial context",
|
||||
])
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
|
||||
|
|
@ -122,7 +123,7 @@ const layer = AppNodeBuilder.build(
|
|||
Job.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
ShellTool.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
|
|
@ -167,6 +168,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
|
|||
const locations = yield* LocationServiceMap.Service
|
||||
const locationLayer = locations.get(location)
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry).pipe(Effect.provide(locationLayer))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, settleTool, testModel, toolIdentity } from "./lib/tool"
|
||||
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
|
||||
|
|
@ -98,7 +99,7 @@ const layer = AppNodeBuilder.build(
|
|||
Job.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
SubagentTool.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
SessionExecution.node,
|
||||
|
|
@ -142,6 +143,7 @@ describe("SubagentTool", () => {
|
|||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
SubagentTool.name,
|
||||
)
|
||||
|
|
@ -175,6 +177,7 @@ describe("SubagentTool", () => {
|
|||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
@ -226,6 +229,7 @@ describe("SubagentTool", () => {
|
|||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
|
|
@ -257,6 +261,7 @@ describe("SubagentTool", () => {
|
|||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue