New top-level packages/cordis/ group with the self-referential toolset: cordis_inspect (services / plugin tree / tools / dynamic mounts / api / events, the api section intersecting the generated catalog with the live service store), cordis_mount (model-written code evaluated in a node:vm sandbox, mounted under one cordis-dynamic group fiber as dyn-<n>), cordis_unmount (awaited disposal to quiescence). Boundary mechanisms: dual-realm instanceof, JSON realm normalization of dynamic tool results, marker-guarded registration, SchemaSpec teaching errors, parse failures surfaced with the offending line + caret and a line-scoped TypeScript hint, and the unmount-first recipe on tool-name collisions. Config: vmTimeoutMs (schemastery, default 5000). Design record: docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. The tool-catalog boot manifest, its regenerated output, and the pinned tool-name list land here rather than with the other repo registration: the completeness guard globs packages/*/tool-* and fails the generator (and the core/tools spec) the moment the package directory exists.
83 lines
3.3 KiB
TypeScript
83 lines
3.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
import * as tool from '../src/index.ts'
|
|
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
|
|
|
/**
|
|
* Disposal semantics: `cordis_unmount` reaches quiescence before returning,
|
|
* and disposing the tool-cordis fiber itself (the HMR path) cascades over the
|
|
* whole dynamic subtree through the ordinary parent→child fiber lifecycle.
|
|
*/
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('cordis_unmount', () => {
|
|
it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => {
|
|
const ctx = await setup()
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
|
|
|
ctx.tools.register(dummyTool('trigger_before'))
|
|
expect(log).toHaveBeenCalledTimes(1)
|
|
|
|
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
|
expect(result.isError).toBe(false)
|
|
expect(text(result)).toContain('unmounted dyn-1')
|
|
|
|
// Immediately after the awaited unmount, the listener must be gone — no
|
|
// grace period, no eventual consistency.
|
|
ctx.tools.register(dummyTool('trigger_after'))
|
|
expect(log).toHaveBeenCalledTimes(1)
|
|
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
|
|
})
|
|
|
|
it('unregisters a self-made tool on unmount', async () => {
|
|
const ctx = await setup()
|
|
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
|
expect(ctx.tools.get('reverse_text')).toBeDefined()
|
|
|
|
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
|
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
|
})
|
|
|
|
it('rejects an unknown id, and a second unmount of the same id', async () => {
|
|
const ctx = await setup()
|
|
const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
|
|
expect(unknown.isError).toBe(true)
|
|
expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"')
|
|
|
|
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
|
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
|
const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
|
expect(again.isError).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('HMR safety', () => {
|
|
it('disposing the tool-cordis fiber cascades over the dynamic subtree and its registrations', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
const fiber = await ctx.plugin(tool)
|
|
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
|
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
|
expect(ctx.tools.get('reverse_text')).toBeDefined()
|
|
|
|
await fiber.dispose()
|
|
|
|
// The whole subtree is gone: the self-made tool, the cordis tools, and the
|
|
// mounted listener (no log on a fresh tools/change).
|
|
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
|
expect(ctx.tools.get('cordis_mount')).toBeUndefined()
|
|
const calls = log.mock.calls.length
|
|
ctx.tools.register(dummyTool('trigger_post_dispose'))
|
|
expect(log).toHaveBeenCalledTimes(calls)
|
|
})
|
|
})
|