mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(tool-cordis): flatten the inspect plugins section to a capability list
The tree SHAPE was the wrong surface for the model: what it needs from cordis_inspect is what services, plugins, and capabilities are loaded, not the fiber hierarchy. The plugins section is now a flat name + lifecycle-state list from ctx.registry (deterministically sorted, one line per instance); the ASCII tree renderer, the parent→child rebuild, and the dyn-id tree annotation are deleted — dynamic mounts keep their own richer dynamic section (id, state, provides, waits). Net -49 lines; RFC and READMEs state the flat-list contract.
This commit is contained in:
@@ -22,11 +22,11 @@ The trust stance, stated once and threaded through the rest: the `node:vm` sandb
|
||||
| `cordis_mount` | Evaluates `code` (the body of an async JavaScript function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted as a child of the `cordis-dynamic` group fiber and tracked under a fresh id (`dyn-1`, `dyn-2`, …). |
|
||||
| `cordis_unmount` | Disposes one dynamic mount by id and returns only after disposal reaches quiescence — every registration the plugin made is unwound, not merely requested to stop. |
|
||||
|
||||
`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (the whole plugin fiber tree rebuilt from `ctx.registry`, ASCII, dynamic mounts annotated with their ids), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../tool-catalog.md) is their exhaustive rendering.
|
||||
`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (a flat list of every loaded plugin with its lifecycle state, from `ctx.registry` — what capabilities are loaded, deliberately not the tree shape), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../tool-catalog.md) is their exhaustive rendering.
|
||||
|
||||
### Sandbox semantics
|
||||
|
||||
Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of an async function under a per-mount filename (`cordis-mount-<id>.js`). The vm gives the code a fresh realm: writes to `globalThis` stay inside the sandbox, and no Node API is provided — capability access is routed through the cordis services (`ctx.fs` for files, `ctx.web` for HTTP, `ctx.bash` for processes, the `ctx.timer` helpers for timing), never Node built-ins, so everything a mounted plugin does stays inspectable through the fiber tree and disposable with it. The `vmTimeoutMs` config bounds only the synchronous portion of evaluation; an async body escapes the bound (acceptable under the trust stance above).
|
||||
Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of an async function under a per-mount filename (`cordis-mount-<id>.js`). The vm gives the code a fresh realm: writes to `globalThis` stay inside the sandbox, and no Node API is provided — capability access is routed through the cordis services (`ctx.fs` for files, `ctx.web` for HTTP, `ctx.bash` for processes, the `ctx.timer` helpers for timing), never Node built-ins, so everything a mounted plugin does stays inspectable through `cordis_inspect` and disposable with its fiber. The `vmTimeoutMs` config bounds only the synchronous portion of evaluation; an async body escapes the bound (acceptable under the trust stance above).
|
||||
|
||||
Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
|
||||
|
||||
@@ -36,7 +36,7 @@ Boundary errors are written around the mistakes models actually make (see [Conse
|
||||
|
||||
### The dynamic group and mount lifecycle
|
||||
|
||||
Every dynamic mount is a child of a single `cordis-dynamic` group fiber, itself a child of the `tool-cordis` plugin's fiber. The group exists so the mounts form one subtree: they read as a unit in the inspect tree, and disposing `tool-cordis` (HMR reload, config unload) cascades over every mount through the ordinary parent→child fiber lifecycle — no bespoke cleanup. Mounting settles before it reports: the returned fiber is `await()`ed, and a startup error (a throwing `apply`, a duplicate tool name, a duplicate service) disposes the fiber and surfaces as the tool error, so a failed mount never lingers. A settled fiber that is not active is a legal pending mount — cordis semantics for unsatisfied `inject` — kept mounted and reported with what it waits for. Everything the plugin registers is an effect on its fiber, so `cordis_unmount` is nothing but an awaited `fiber.dispose()`.
|
||||
Every dynamic mount is a child of a single `cordis-dynamic` group fiber, itself a child of the `tool-cordis` plugin's fiber. The group exists so the mounts form one subtree: they are disposed as a unit, and disposing `tool-cordis` (HMR reload, config unload) cascades over every mount through the ordinary parent→child fiber lifecycle — no bespoke cleanup. Mounting settles before it reports: the returned fiber is `await()`ed, and a startup error (a throwing `apply`, a duplicate tool name, a duplicate service) disposes the fiber and surfaces as the tool error, so a failed mount never lingers. A settled fiber that is not active is a legal pending mount — cordis semantics for unsatisfied `inject` — kept mounted and reported with what it waits for. Everything the plugin registers is an effect on its fiber, so `cordis_unmount` is nothing but an awaited `fiber.dispose()`.
|
||||
|
||||
### Cross-mount composition via provide/inject
|
||||
|
||||
@@ -64,7 +64,7 @@ Model-visible ⟺ logged holds with no new session event type: a mount or unmoun
|
||||
| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
|
||||
| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — a surface that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
|
||||
| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
|
||||
| Inspectability | Registers something the plugin tree cannot show as a plugin | What the model mounts is exactly what `cordis_inspect` renders |
|
||||
| Inspectability | Registers something the plugin list cannot show as a plugin | What the model mounts is exactly what `cordis_inspect` renders |
|
||||
| Model ergonomics | Wins for the single most common case (no plugin boilerplate) | Mitigated by the canonical recipe in the mount description plus boundary errors that teach the fix |
|
||||
|
||||
The correctness investment therefore goes where it pays for every capability at once: the generated API catalog surfaced through `cordis_inspect`, and sandbox-boundary validation whose error messages teach the correct call. A structured registration tool remains addable later as sugar that synthesizes mount code; nothing here forecloses it.
|
||||
|
||||
@@ -110,7 +110,7 @@ The bash/bash_output/bash_kill tools are model-facing consumers of the bash exec
|
||||
|
||||
### `cordis_inspect`
|
||||
|
||||
Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (the whole plugin fiber tree with lifecycle states, as an ASCII tree — dynamic mounts appear under the `cordis-dynamic` group with their ids), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.
|
||||
Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# packages/cordis — the self-referential runtime toolset
|
||||
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the plugin tree and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -4,7 +4,7 @@ The self-referential cordis toolset: three model-facing tools over the live runt
|
||||
|
||||
## What it does
|
||||
|
||||
- `cordis_inspect` — read-only report over the runtime: services, the plugin fiber tree (ASCII), registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references.
|
||||
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references.
|
||||
- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
|
||||
- `cordis_unmount` — disposes one mount by id, returning only after quiescence.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Runtime mirror of the cordis `FiberState` const enum plus human-readable
|
||||
* labels, shared by the mount lifecycle (state reporting) and the inspect
|
||||
* renderers (tree and mount-table labels).
|
||||
* renderers (plugin-list and mount-table labels).
|
||||
*
|
||||
* Cordis exposes `FiberState` as a `const enum`: there is no runtime object for
|
||||
* Node's type-stripping runner to import, so the members are mirrored here as
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* The self-referential cordis toolset: three model-facing tools that let the
|
||||
* agent inspect and MODIFY the live cordis runtime it is running inside.
|
||||
*
|
||||
* - `cordis_inspect` — read-only: provided services, the plugin fiber tree
|
||||
* (rendered as an ASCII tree), registered tools, the dynamic mounts, and the
|
||||
* - `cordis_inspect` — read-only: provided services, the flat plugin list
|
||||
* with lifecycle states, registered tools, the dynamic mounts, and the
|
||||
* catalog-backed `api` / `events` references.
|
||||
* - `cordis_mount` — evaluate model-written code in a `node:vm` sandbox; the
|
||||
* code returns a cordis plugin, which is mounted as a child of a dedicated
|
||||
@@ -14,8 +14,8 @@
|
||||
* `harness.registerTool`, services via `ctx.provide`) is an effect on the
|
||||
* dynamic fiber, so unmounting — or disposing this plugin itself (HMR) — cleans
|
||||
* it all up through the ordinary cordis lifecycle. The group fiber exists
|
||||
* exactly so the dynamic mounts form ONE subtree: visible as a unit in the
|
||||
* inspect tree and disposed as a unit with this plugin. Design home:
|
||||
* exactly so the dynamic mounts form ONE subtree, disposed as a unit with
|
||||
* this plugin. Design home:
|
||||
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
|
||||
*
|
||||
* The vm sandbox guards against ACCIDENTAL global pollution only — it is not a
|
||||
@@ -31,12 +31,12 @@
|
||||
* @module @deepseek-ai/dsh-tool-cordis
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { STATE_LABELS } from './fiber-state.ts'
|
||||
import { isPlugin, pluginName } from './guard.ts'
|
||||
import { describeApi, describeDynamic, describeEvents, describePluginTree, describeServices, describeTools } from './inspect.ts'
|
||||
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
|
||||
import { missingServices, mountDynamic } from './mount.ts'
|
||||
import type { DynamicMount } from './mount.ts'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
|
||||
@@ -79,21 +79,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const mounts = new Map<string, DynamicMount>()
|
||||
let nextId = 1
|
||||
|
||||
/** The dynamic-mount id for a fiber, when that fiber is a tracked mount. */
|
||||
function mountIdOf(fiber: Fiber): string | undefined {
|
||||
for (const [id, mount] of mounts) {
|
||||
if (mount.fiber === fiber) return id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect',
|
||||
description:
|
||||
'Inspect the live cordis runtime that is running THIS agent. Read-only. '
|
||||
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
|
||||
+ '`plugins` (the whole plugin fiber tree with lifecycle states, as an ASCII tree — '
|
||||
+ 'dynamic mounts appear under the `cordis-dynamic` group with their ids), '
|
||||
+ '`plugins` (a flat list of the loaded plugins with their lifecycle states), '
|
||||
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
|
||||
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
|
||||
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
|
||||
@@ -109,7 +100,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
execute(args): Promise<{ type: 'text'; text: string }[]> {
|
||||
const sections: [heading: string, body: () => string[]][] = [
|
||||
['services', () => describeServices(ctx)],
|
||||
['plugins', () => describePluginTree(ctx, mountIdOf)],
|
||||
['plugins', () => describePlugins(ctx)],
|
||||
['tools', () => describeTools(ctx)],
|
||||
['dynamic', () => describeDynamic(ctx, mounts)],
|
||||
['api', () => describeApi(ctx)],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Read-only renderers over the live runtime for `cordis_inspect`: the service
|
||||
* list, the plugin fiber tree (ASCII), the registered tools, the dynamic-mount
|
||||
* list, the flat plugin list, the registered tools, the dynamic-mount
|
||||
* table (with per-mount provides/waits), and the catalog-backed `api` /
|
||||
* `events` sections. Every renderer is a pure function of the runtime handles
|
||||
* it receives — no session state, no clock — so inspect output is exactly the
|
||||
@@ -57,56 +57,22 @@ export function describeServices(ctx: Context): string[] {
|
||||
return lines.length > 0 ? lines : ['(no services provided)']
|
||||
}
|
||||
|
||||
/** The tree node shape {@link renderTree} draws: one line per fiber, children indented. */
|
||||
interface TreeNode {
|
||||
label: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
/** Render a node list as an ASCII tree (`├─`/`└─` box drawing). */
|
||||
function renderTree(nodes: TreeNode[], prefix = ''): string[] {
|
||||
return nodes.flatMap((node, index) => {
|
||||
const last = index === nodes.length - 1
|
||||
const line = `${prefix}${last ? '└─' : '├─'} ${node.label}`
|
||||
const childPrefix = `${prefix}${last ? ' ' : '│ '}`
|
||||
return [line, ...renderTree(node.children, childPrefix)]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `plugins` section: every fiber the registry knows, rebuilt into the
|
||||
* parent→child tree from each fiber's mounting context and rendered as an
|
||||
* ASCII tree with lifecycle states. Fibers whose parent fiber is outside the
|
||||
* registry (i.e. mounted on the root context) become roots.
|
||||
* @param ctx - the runtime whose registry is walked.
|
||||
* @param mountIdOf - resolves a fiber to its dynamic-mount id, so mounts render as `dyn-<n>: name`.
|
||||
* @returns the tree lines, starting at the synthetic `root` line.
|
||||
* The `plugins` section: a flat list of every fiber the registry knows, one
|
||||
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
|
||||
* mounted more than once repeats — one line per instance). Dynamic mounts are
|
||||
* listed like any other plugin; their ids live in the `dynamic` section.
|
||||
* @param ctx - the runtime whose registry is enumerated.
|
||||
* @returns one line per loaded plugin fiber.
|
||||
*/
|
||||
export function describePluginTree(ctx: Context, mountIdOf: (fiber: Fiber) => string | undefined): string[] {
|
||||
const fibers = new Set<Fiber>()
|
||||
export function describePlugins(ctx: Context): string[] {
|
||||
const fibers: Fiber[] = []
|
||||
for (const runtime of ctx.registry.values()) {
|
||||
for (const fiber of runtime.fibers) fibers.add(fiber)
|
||||
for (const fiber of runtime.fibers) fibers.push(fiber)
|
||||
}
|
||||
const childrenOf = new Map<Fiber, Fiber[]>()
|
||||
const roots: Fiber[] = []
|
||||
for (const fiber of fibers) {
|
||||
const parent = fiber.parent.fiber
|
||||
if (fibers.has(parent)) {
|
||||
const siblings = childrenOf.get(parent) ?? []
|
||||
siblings.push(fiber)
|
||||
childrenOf.set(parent, siblings)
|
||||
} else {
|
||||
roots.push(fiber)
|
||||
}
|
||||
}
|
||||
const byUid = (a: Fiber, b: Fiber): number => (a.uid ?? Infinity) - (b.uid ?? Infinity)
|
||||
const toNode = (fiber: Fiber): TreeNode => {
|
||||
const id = mountIdOf(fiber)
|
||||
const label = `${id ? `${id}: ` : ''}${fiber.name} [${STATE_LABELS[fiber.state]}]`
|
||||
const children = (childrenOf.get(fiber) ?? []).sort(byUid).map(toNode)
|
||||
return { label, children }
|
||||
}
|
||||
return ['root', ...renderTree(roots.sort(byUid).map(toNode))]
|
||||
return fibers
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { FiberState } from '../src/fiber-state.ts'
|
||||
import { describeApi, describeEvents, describePluginTree, describeServices } from '../src/inspect.ts'
|
||||
import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts'
|
||||
import { call, LISTENER_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The `cordis_inspect` sections: rendered against the real runtime through the
|
||||
* tool, plus direct renderer calls for the states a minimal harness cannot
|
||||
* reach (empty service store, uid-less fibers, a fully-live catalog).
|
||||
* reach (empty service store, same-named sibling fibers, a fully-live catalog).
|
||||
*/
|
||||
|
||||
describe('cordis_inspect', () => {
|
||||
@@ -19,11 +19,12 @@ describe('cordis_inspect', () => {
|
||||
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
|
||||
expect(report).toContain(`## ${heading}`)
|
||||
}
|
||||
// The services section sees the real providers; the tree shows the dynamic
|
||||
// group under this plugin; the tools section lists the cordis tools.
|
||||
// The services section sees the real providers; the plugins list shows
|
||||
// this plugin and its dynamic group flat; the tools section lists the
|
||||
// cordis tools.
|
||||
expect(report).toContain('- tools (provided by ToolRegistry)')
|
||||
expect(report).toMatch(/tool-cordis \[active\]/)
|
||||
expect(report).toMatch(/cordis-dynamic \[active\]/)
|
||||
expect(report).toContain('- tool-cordis [active]')
|
||||
expect(report).toContain('- cordis-dynamic [active]')
|
||||
expect(report).toContain('- cordis_mount')
|
||||
expect(report).toContain('(no dynamic plugins mounted)')
|
||||
})
|
||||
@@ -37,12 +38,12 @@ describe('cordis_inspect', () => {
|
||||
expect(report).not.toContain('## plugins')
|
||||
})
|
||||
|
||||
it('shows a mount in the dynamic section and as an annotated child of the group in the tree', async () => {
|
||||
it('shows a mount in the dynamic section and in the flat plugins list', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
const report = text(await call(ctx, 'cordis_inspect', {}))
|
||||
expect(report).toContain('- dyn-1: change-logger [active]')
|
||||
expect(report).toMatch(/dyn-1: change-logger \[active\]/)
|
||||
expect(report).toContain('- change-logger [active]')
|
||||
})
|
||||
|
||||
it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
|
||||
@@ -87,22 +88,15 @@ describe('inspect renderers (direct)', () => {
|
||||
expect(describeServices(ctx)).toEqual(['- thing (provided by half-loaded, pending)'])
|
||||
})
|
||||
|
||||
it('describePluginTree sorts uid-less fibers last and renders sibling branches', () => {
|
||||
// The parent fiber is OUTSIDE the registry set, so all three are roots.
|
||||
const rootFiber = { uid: 0, name: 'root' } as unknown as Fiber
|
||||
const fiber = (uid: number | null, name: string): Fiber =>
|
||||
({ uid, name, state: FiberState.ACTIVE, parent: { fiber: rootFiber } }) as unknown as Fiber
|
||||
const a = fiber(2, 'beta')
|
||||
const b = fiber(1, 'alpha')
|
||||
const c = fiber(null, 'rootless')
|
||||
const d = fiber(null, 'rootless-too')
|
||||
const ctx = { registry: { values: () => [{ fibers: [a, b, c, d] }] } } as unknown as Context
|
||||
expect(describePluginTree(ctx, () => undefined)).toEqual([
|
||||
'root',
|
||||
'├─ alpha [active]',
|
||||
'├─ beta [active]',
|
||||
'├─ rootless [active]',
|
||||
'└─ rootless-too [active]',
|
||||
it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
|
||||
const fiber = (name: string): Fiber => ({ name, state: FiberState.ACTIVE }) as unknown as Fiber
|
||||
const ctx = {
|
||||
registry: { values: () => [{ fibers: [fiber('beta'), fiber('alpha')] }, { fibers: [fiber('alpha')] }] },
|
||||
} as unknown as Context
|
||||
expect(describePlugins(ctx)).toEqual([
|
||||
'- alpha [active]',
|
||||
'- alpha [active]',
|
||||
'- beta [active]',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user