Files
deepseek-harness/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts
imccyu ee1da1ce5b feat(cordis): @deepseek-ai/dsh-tool-cordis — inspect/mount/unmount over the live runtime
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.
2026-07-09 13:57:03 +08:00

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)
})
})