Netx tools: load from netx remote /mcp, add per-tool toggles

- Drop the TypeScript netx tool implementations and the hard-coded
  TOOLS_BY_GROUP list; register netx__* tools from the server's
  tools/list (group from _meta.netx.group) and forward tools/call.
- Thin Streamable HTTP client on standard fetch (fetchImpl injectable);
  cached catalog when netx is unreachable, refresh to reconnect.
- disabledTools (deny list; new server tools follow their group) and
  readOnlyOnly (only readOnlyHint=true tools), re-registered live.
- Settings card: per-group expandable tool rows with switches and a
  write marker; skills append a disabled-tools note.
- Toggles only control model visibility; netx enforces security.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 22:08:22 +08:00
parent 2def53e7c7
commit 2ef3a43c63
38 changed files with 5049 additions and 17029 deletions

View file

@ -12,8 +12,6 @@ import {
groupsForced,
kbEnabledForPlane,
SKILL_DIR_BY_GROUP,
toolNamesForGroups,
TOOLS_BY_GROUP,
} from '../src/netx/capability-groups.ts'
test('defaults: ops + bizMonitor in preset; topology and public off', () => {
@ -74,14 +72,12 @@ test('preset and host planes are symmetric; only defaults differ', () => {
})
test('null boolean flags do not resurrect defaults incorrectly', () => {
// Settings projection may emit null for volatile booleans.
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: /** @type {any} */ (null),
groupOpsPublic: /** @type {any} */ (null),
groupBizMonitorInPreset: /** @type {any} */ (null),
groupBizMonitorPublic: /** @type {any} */ (null),
})
// null is not a boolean → fall back to defaults (ops+bizMonitor inPreset, public off)
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(groups, 'host'), [])
})
@ -95,24 +91,12 @@ test('kbEnabledForPlane mirrors inPreset/public symmetry', () => {
assert.equal(kbEnabledForPlane({}, 'host'), false)
})
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
test('one group one skill dir', () => {
assert.deepEqual(SKILL_DIR_BY_GROUP, {
ops: 'ops',
topology: 'topology',
bizMonitor: 'biz-monitor',
})
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__getNeExecJob'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__queryNmsAlarms'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
assert.equal(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'), false)
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw'))
})
test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => {
@ -124,10 +108,6 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
})
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
assert.equal(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
})
test('legacy groupNms/common/managedNe map to ops; absent topology flags use legacy layout', () => {

View file

@ -0,0 +1,45 @@
/**
* Skill injection appends disabled-tool footnotes when tools are filtered.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { registerGroupSkills } from '../src/netx/group-skills.ts'
test('registerGroupSkills appends disabled footnote; skips disabled groups', async () => {
/** @type {Array<{ name: string, content: string }>} */
const registered = []
const ctx = {
skills: {
register(skill) {
registered.push({ name: skill.name, content: skill.content })
return () => {}
},
},
logger: { warn() {} },
}
const dispose = await registerGroupSkills(ctx, ['ops'], 'test', {
disabledByGroup: { ops: ['netx__execManagedNe'] },
lang: 'zh',
})
assert.ok(registered.length >= 1)
assert.match(registered[0].content, /以下工具已禁用:netx__execManagedNe,不要调用/)
dispose()
registered.length = 0
const dispose2 = await registerGroupSkills(ctx, [], 'test', {
disabledByGroup: { ops: ['netx__execManagedNe'] },
})
assert.equal(registered.length, 0)
dispose2()
registered.length = 0
const dispose3 = await registerGroupSkills(ctx, ['ops'], 'test', {
disabledByGroup: { ops: ['netx__execManagedNe'] },
lang: 'en',
})
assert.match(registered[0].content, /The following tools are disabled: netx__execManagedNe/)
dispose3()
})

223
test/mcp-catalog.test.mjs Normal file
View file

@ -0,0 +1,223 @@
/**
* MCP catalog filtering: groups × disabledTools × readOnlyOnly.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import http from 'node:http'
import {
filterCatalogTools,
parseDisabledTools,
resolveEnabledTools,
disabledPublicNames,
} from '../src/netx/mcp-catalog.ts'
import { listMcpTools, callMcpTool, mcpEndpointUrl } from '../src/netx/mcp-remote.ts'
import { refreshMcpCatalog, getMcpCatalogState, resetMcpCatalog } from '../src/netx/mcp-store.ts'
import { DEFAULT_CAPABILITY_GROUPS } from '../src/netx/capability-groups.ts'
const SAMPLE = Object.freeze([
{
name: 'queryNmsAlarms',
description: 'alarms',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'ops',
},
{
name: 'execManagedNe',
description: 'cli',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: false,
group: 'ops',
},
{
name: 'getTopologyTree',
description: 'tree',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'topology',
},
{
name: 'listBizMonitors',
description: 'biz',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'bizMonitor',
},
{
// Server later adds this under ops — must follow group default.
name: 'brandNewOpsTool',
description: 'new',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'ops',
},
])
test('parseDisabledTools accepts JSON and CSV', () => {
assert.deepEqual(parseDisabledTools('["execManagedNe","x"]'), ['execManagedNe', 'x'])
assert.deepEqual(parseDisabledTools('execManagedNe, queryNmsAlarms'), ['execManagedNe', 'queryNmsAlarms'])
assert.deepEqual(parseDisabledTools('[]'), [])
})
test('group off drops all tools in that group', () => {
const enabled = filterCatalogTools(SAMPLE, ['ops', 'bizMonitor'], {})
assert.deepEqual(enabled.map((t) => t.name).sort(), [
'brandNewOpsTool',
'execManagedNe',
'listBizMonitors',
'queryNmsAlarms',
])
})
test('disabledTools opts out one tool while group stays on', () => {
const enabled = filterCatalogTools(SAMPLE, ['ops'], { disabledTools: ['execManagedNe'] })
assert.deepEqual(enabled.map((t) => t.name).sort(), ['brandNewOpsTool', 'queryNmsAlarms'])
})
test('readOnlyOnly drops writable tools', () => {
const enabled = filterCatalogTools(SAMPLE, ['ops'], { readOnlyOnly: true })
assert.deepEqual(enabled.map((t) => t.name).sort(), ['brandNewOpsTool', 'queryNmsAlarms'])
})
test('new server tool follows group default (not listed in disabledTools)', () => {
const enabled = resolveEnabledTools(SAMPLE, {
plane: 'preset',
groups: DEFAULT_CAPABILITY_GROUPS,
disabledTools: ['execManagedNe'],
})
const names = enabled.map((t) => t.name)
assert.ok(names.includes('brandNewOpsTool'))
assert.ok(names.includes('queryNmsAlarms'))
assert.ok(names.includes('listBizMonitors'))
assert.equal(names.includes('execManagedNe'), false)
assert.equal(names.includes('getTopologyTree'), false) // topology inPreset default off
})
test('disabledPublicNames lists model-facing names for skill footnotes', () => {
const names = disabledPublicNames(SAMPLE, ['ops'], ['execManagedNe'], true)
assert.deepEqual(names, ['netx__execManagedNe'])
})
test('mcpEndpointUrl appends /mcp', () => {
assert.equal(mcpEndpointUrl('http://127.0.0.1:8890'), 'http://127.0.0.1:8890/mcp')
assert.equal(mcpEndpointUrl('http://127.0.0.1:8890/'), 'http://127.0.0.1:8890/mcp')
})
function startMockMcp(tools, { failList = false, token = 'secret' } = {}) {
let callCount = 0
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
return
}
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const auth = req.headers.authorization ?? ''
if (token && auth !== `Bearer ${token}`) {
res.writeHead(401)
res.end('unauthorized')
return
}
const reply = (result) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id ?? 1, result }))
}
if (body.method === 'initialize') {
reply({
protocolVersion: '2025-03-26',
capabilities: { tools: {} },
serverInfo: { name: 'mock-netx', version: '0.0.0' },
})
return
}
if (body.method === 'notifications/initialized') {
res.writeHead(202)
res.end()
return
}
if (body.method === 'tools/list') {
if (failList) {
res.writeHead(503)
res.end('down')
return
}
reply({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
annotations: { readOnlyHint: t.readOnlyHint },
_meta: { netx: { group: t.group } },
})),
})
return
}
if (body.method === 'tools/call') {
callCount += 1
reply({
content: [{ type: 'text', text: JSON.stringify({ ok: true, name: body.params?.name }) }],
isError: false,
})
return
}
res.writeHead(400)
res.end('unknown method')
})
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
resolve({
server,
url: `http://127.0.0.1:${port}/mcp`,
apiUrl: `http://127.0.0.1:${port}`,
callCount: () => callCount,
})
})
})
}
test('listMcpTools + callMcpTool against mock Streamable HTTP server', async () => {
const mock = await startMockMcp(SAMPLE)
try {
const tools = await listMcpTools({ mcpUrl: mock.url, token: 'secret' })
assert.equal(tools.length, SAMPLE.length)
assert.equal(tools.find((t) => t.name === 'execManagedNe')?.readOnlyHint, false)
assert.equal(tools.find((t) => t.name === 'queryNmsAlarms')?.group, 'ops')
const result = await callMcpTool({ mcpUrl: mock.url, token: 'secret' }, 'queryNmsAlarms', {})
assert.equal(result.isError, undefined)
assert.ok(Array.isArray(result.content))
assert.ok(mock.callCount() >= 1)
} finally {
mock.server.close()
}
})
test('refreshMcpCatalog keeps cache on disconnect', async () => {
resetMcpCatalog()
const mock = await startMockMcp(SAMPLE)
try {
const ok = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
assert.equal(ok.phase, 'connected')
assert.equal(ok.tools.length, SAMPLE.length)
assert.equal(ok.fromCache, false)
} finally {
mock.server.close()
}
const offline = await startMockMcp(SAMPLE, { failList: true })
try {
const next = await refreshMcpCatalog({ apiUrl: offline.apiUrl, token: 'secret' })
assert.equal(next.phase, 'offline')
assert.equal(next.fromCache, true)
assert.equal(next.tools.length, SAMPLE.length)
assert.ok(next.error)
assert.equal(getMcpCatalogState().tools.length, SAMPLE.length)
} finally {
offline.server.close()
resetMcpCatalog()
}
})

116
test/mcp-test-catalog.mjs Normal file
View file

@ -0,0 +1,116 @@
/**
* Seed the process-local MCP catalog for registration / publication tests.
*/
import { publishNetxConnection } from '../src/netx/runtime.ts'
const STORE_KEY = Symbol.for('dsh-netxops.mcp-catalog-store')
/** Classic ops catalog size (matches historical TOOLS_BY_GROUP.ops.length). */
export const OPS_TOOL_NAMES = Object.freeze([
'queryNmsAlarms',
'aggregateNmsAlarms',
'runNmsDiagnostics',
'queryNmsNeInventory',
'getNmsNe',
'queryNmsAlarmsRaw',
'aggregateNmsAlarmsRaw',
'listNmsAlarmFields',
'sqlQueryNms',
'listManagedNe',
'getManagedNe',
'execManagedNe',
'getNeExecJob',
'listCliTargets',
'findTopologyPaths',
])
export const TOPOLOGY_TOOL_NAMES = Object.freeze([
'getTopologyTree',
'getTopologyView',
'createTopologyFolder',
'addTopologyViewNodes',
'removeTopologyViewNodes',
'copyTopologyViewNodes',
'updateTopologyViewPositions',
'projectTopologyNeighbors',
'queryTopologyFabricNodes',
'classifyTopologyFabricNodes',
'queryTopologyNeighborhood',
'queryTopologyEdges',
'layoutTopologyView',
'suggestSinkHubs',
'analyzeTopologyViewLayout',
])
export const BIZ_MONITOR_TOOL_NAMES = Object.freeze([
'listBizMonitors',
'listBizMonitorBatches',
'getBizMonitorContext',
'getBizMonitorBoard',
'listBizMonitorReds',
'getBizMonitorDiffs',
'getBizCollectBatch',
'getBizCollectCommandRaw',
])
const WRITE_OPS = new Set(['execManagedNe'])
const WRITE_TOPOLOGY = new Set([
'createTopologyFolder',
'addTopologyViewNodes',
'removeTopologyViewNodes',
'copyTopologyViewNodes',
'updateTopologyViewPositions',
'projectTopologyNeighbors',
'classifyTopologyFabricNodes',
'layoutTopologyView',
])
function descriptor(name, group) {
const write = (group === 'ops' && WRITE_OPS.has(name))
|| (group === 'topology' && WRITE_TOPOLOGY.has(name))
return {
name,
description: name,
inputSchema: { type: 'object', properties: {} },
readOnlyHint: !write,
group,
}
}
export function fullCatalog() {
return [
...OPS_TOOL_NAMES.map((name) => descriptor(name, 'ops')),
...TOPOLOGY_TOOL_NAMES.map((name) => descriptor(name, 'topology')),
...BIZ_MONITOR_TOOL_NAMES.map((name) => descriptor(name, 'bizMonitor')),
]
}
/** Install a frozen catalog into the shared Symbol.for store (no network). */
export function seedMcpCatalog(tools = fullCatalog()) {
const root = globalThis
const store = {
state: {
phase: 'connected',
mcpUrl: 'http://127.0.0.1:8890/mcp',
tools: Object.freeze([...tools]),
error: '',
listedAt: new Date().toISOString(),
fromCache: false,
},
listeners: new Set(),
generation: 1,
}
root[STORE_KEY] = store
return store.state
}
export function clearMcpCatalogSeed() {
delete globalThis[STORE_KEY]
}
/** Convenience: seed catalog + publish a connection snapshot. */
export function publishWithCatalog(connection) {
seedMcpCatalog()
publishNetxConnection(connection)
}

View file

@ -11,6 +11,7 @@ import { registerGroupSkills } from '../src/netx/group-skills.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { getToolMountStatuses, publishToolMountStatus, resetToolMountStatus } from '../src/netx/tool-mount-status.ts'
import { asToolsMountView } from '../src/client/tools-status-view.ts'
import { OPS_TOOL_NAMES, clearMcpCatalogSeed, seedMcpCatalog } from './mcp-test-catalog.mjs'
const log = { info() {}, warn() {}, error() {} }
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
@ -37,7 +38,12 @@ function fakeContext(failAt = -1) {
function diagnosticHome(t) {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-publication-'))
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
seedMcpCatalog()
t.after(() => {
clearMcpCatalogSeed()
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
})
}
test('selected presets are isolated and global publication is inherited once', () => {
@ -67,7 +73,7 @@ test('failed tool registration rolls back every earlier tool', t => {
const { ctx, tools } = fakeContext(3)
assert.throws(() => registerNetxTools(ctx, connection(), { plane: 'preset' }), /registration refused/)
assert.equal(tools.size, 0)
assert.ok(getToolMountStatuses().some(row => row.error === 'registration refused'))
assert.ok(getToolMountStatuses().some(row => String(row.error ?? '').includes('registration refused')))
resetToolMountStatus()
})
@ -87,7 +93,7 @@ test('turning off a mounted group revokes tools, KB context and pending skill lo
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
const mount = fakeContext()
applyGroupToolsPlugin(mount.ctx, { name: 'lifecycle', mode: 'settings' })
assert.equal(mount.tools.size, 15)
assert.equal(mount.tools.size, OPS_TOOL_NAMES.length)
assert.equal(mount.skills.has('kb-context'), true)
publishNetxConnection(connection({ toolsEnabled: false }))
await pause()
@ -95,7 +101,7 @@ test('turning off a mounted group revokes tools, KB context and pending skill lo
assert.equal(mount.skills.size, 0)
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
await pause()
assert.equal(mount.tools.size, 15)
assert.equal(mount.tools.size, OPS_TOOL_NAMES.length)
assert.equal(mount.skills.has('netx-ops'), true)
mount.dispose()
await pause()
@ -123,9 +129,9 @@ test('mount inventory survives cross-bundle publishes and reports all scopes', a
entry.apply(mount.ctx)
const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.id === 'host-test'))
assert.ok(statuses.some(row => row.count === 15))
assert.ok(statuses.some(row => row.count === OPS_TOOL_NAMES.length))
const view = asToolsMountView({ mounts: statuses, connection: { toolsEnabled: true } })
assert.equal(view.count, 15)
assert.equal(view.count, OPS_TOOL_NAMES.length)
mount.dispose()
assert.equal(getToolMountStatuses().some(row => row.count > 0), false)
resetToolMountStatus()

View file

@ -13,6 +13,12 @@ import { npmRuntime } from '../scripts/npm-cli.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
const { node, npm } = npmRuntime()
/** Inlined into the activate smoke so the packed Host can register without netx. */
const OPS_TOOL_NAMES_INLINE = [
'queryNmsAlarms', 'aggregateNmsAlarms', 'runNmsDiagnostics', 'queryNmsNeInventory', 'getNmsNe',
'queryNmsAlarmsRaw', 'aggregateNmsAlarmsRaw', 'listNmsAlarmFields', 'sqlQueryNms',
'listManagedNe', 'getManagedNe', 'execManagedNe', 'getNeExecJob', 'listCliTargets', 'findTopologyPaths',
]
const run = (args, cwd, env = {}) => {
const result = spawnSync(node, args, { cwd, env: { ...process.env, ...env }, encoding: 'utf8', windowsHide: true, timeout: 120_000 })
assert.equal(result.status, 0, `${args[0]} failed: ${result.error ?? ''}\n${result.stderr}\n${result.stdout}`)
@ -87,6 +93,14 @@ test('the npm tarball installs offline and supplies every exported artifact and
const root = new Context(), tools = new Map(), skills = new Map(), owners = [];
class Tools extends Service { constructor(ctx) { super(ctx, 'tools') } register(entry) { assert.ok(!tools.has(entry.name)); tools.set(entry.name, entry); return () => tools.delete(entry.name) } }
class Skills extends Service { constructor(ctx) { super(ctx, 'skills') } register(entry) { assert.ok(!skills.has(entry.name)); skills.set(entry.name, entry); return () => skills.delete(entry.name) } }
// Seed a cached /mcp catalog so host registration does not need a live netx.
const STORE = Symbol.for('dsh-netxops.mcp-catalog-store');
const ops = ${JSON.stringify([...OPS_TOOL_NAMES_INLINE])};
globalThis[STORE] = { generation: 1, listeners: new Set(), state: {
phase: 'connected', mcpUrl: 'http://127.0.0.1:8890/mcp', error: '', listedAt: new Date().toISOString(), fromCache: false,
tools: ops.map(name => ({ name, description: name, inputSchema: { type: 'object', properties: {} },
readOnlyHint: name !== 'execManagedNe', group: 'ops' })),
} };
owners.push(await root.plugin(Tools), await root.plugin(Skills));
const owner = await root.plugin(host, { installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true }); owners.push(owner);
assert.equal(owner.state, 2); assert.equal(tools.size, 15);

View file

@ -8,6 +8,7 @@ import { join } from 'node:path'
import { installPresetPublication } from '../src/netx/preset-publication.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { capabilityGroupsFromSettings } from '../src/netx/capability-groups.ts'
import { OPS_TOOL_NAMES, clearMcpCatalogSeed, seedMcpCatalog } from './mcp-test-catalog.mjs'
const layers = new Map()
const skillLayers = new Map()
@ -47,10 +48,12 @@ const wait = () => new Promise(resolve => setTimeout(resolve, 50))
test('selected publication uses each exact session scope and cleans up on off, switch, and unload', async t => {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-selected-'))
seedMcpCatalog()
const ctx = new Context()
const owners = []
t.after(async () => {
for (const owner of owners.reverse()) await owner.dispose()
clearMcpCatalogSeed()
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
layers.clear(); skillLayers.clear(); live = []
})
@ -78,7 +81,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
const owner = await ctx.plugin(installPresetPublication)
owners.push(owner)
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
assert.equal(layers.get(scopes[0].key)?.size, OPS_TOOL_NAMES.length)
assert.equal(skillLayers.get(scopes[0].key)?.has('netx-ops'), true)
assert.equal(layers.get(scopes[1].key)?.size ?? 0, 0)
publish({ toolsEnabled: false })
@ -87,7 +90,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
assert.equal(skillLayers.get(scopes[0].key)?.size, 0)
publish()
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
assert.equal(layers.get(scopes[0].key)?.size, OPS_TOOL_NAMES.length)
scopes[0].agent.ctx = scopes[0].agent.ctx.extend({ presetId: 'review' })
await ctx.serial('agent-preset/selected', 'standard', 'review')
await wait()
@ -95,7 +98,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
scopes[1].agent.ctx = scopes[1].agent.ctx.extend({ presetId: 'standard' })
await ctx.serial('agent/created', { agent: scopes[1].agent })
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 15)
assert.equal(layers.get(scopes[1].key)?.size, OPS_TOOL_NAMES.length)
await owner.dispose()
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 0)