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.
This commit is contained in:
105
packages/cordis/tool-cordis/tests/cross-mount.spec.ts
Normal file
105
packages/cordis/tool-cordis/tests/cross-mount.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Cross-mount composition through ordinary cordis provide/inject semantics:
|
||||
* one mount provides a service, another injects it, and mount ids stay the
|
||||
* lifecycle handles. Every assertion is against the WORLD — the registry, the
|
||||
* service store, real tool dispatch — not the tool's own summary line.
|
||||
*/
|
||||
|
||||
describe('cross-mount provide/inject', () => {
|
||||
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
expect(text(provider)).toContain('state: active')
|
||||
|
||||
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('state: active')
|
||||
|
||||
// The vm-realm service value is callable across mounts, and the result
|
||||
// normalizes into the host realm like any dynamic tool result.
|
||||
const greeted = await call(ctx, 'greet', { name: 'harness' })
|
||||
expect(greeted.isError).toBe(false)
|
||||
expect(text(greeted)).toBe('hi harness')
|
||||
})
|
||||
|
||||
it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => {
|
||||
const ctx = await setup()
|
||||
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('state: pending')
|
||||
expect(text(consumer)).toContain('waiting for service(s): greeter')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter')
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
expect(ctx.tools.get('greet')).toBeDefined()
|
||||
expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late')
|
||||
})
|
||||
|
||||
it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
|
||||
expect(ctx.tools.get('greet')).toBeDefined()
|
||||
|
||||
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(unmounted.isError).toBe(false)
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
|
||||
expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter')
|
||||
})
|
||||
|
||||
it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
|
||||
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3
|
||||
expect(ctx.tools.get('greet')).toBeDefined()
|
||||
expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]')
|
||||
})
|
||||
|
||||
it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
expect(duplicate.isError).toBe(true)
|
||||
expect(text(duplicate)).toContain('has been registered')
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
|
||||
expect(report).toContain('dyn-1: greeter-provider')
|
||||
expect(report).not.toContain('dyn-2')
|
||||
})
|
||||
|
||||
it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
|
||||
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
|
||||
expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter')
|
||||
|
||||
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
|
||||
expect(services).toContain('- greeter (provided by greeter-provider)')
|
||||
|
||||
const api = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
|
||||
expect(api).toContain('- greeter (provided by greeter-provider, no catalog entry)')
|
||||
})
|
||||
|
||||
it('unmounting the consumer leaves the provider and its service intact', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
|
||||
await call(ctx, 'cordis_unmount', { id: 'dyn-2' })
|
||||
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
|
||||
expect(services).toContain('- greeter (provided by greeter-provider)')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]')
|
||||
})
|
||||
})
|
||||
104
packages/cordis/tool-cordis/tests/helpers.ts
Normal file
104
packages/cordis/tool-cordis/tests/helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Context } from 'cordis'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer +
|
||||
* tool-cordis tree (only the model is absent — the code strings below stand in
|
||||
* for what it would write), plus the canonical mount-code fixtures the suites
|
||||
* share.
|
||||
*/
|
||||
|
||||
/** Mount the plugin on a fresh context with a real ToolRegistry and the timer service. */
|
||||
export async function setup(config?: tool.Config): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(tool, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
|
||||
/** Execute a registered tool through the real registry pipeline. */
|
||||
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
}
|
||||
|
||||
/** Concatenated text blocks of one tool result. */
|
||||
export function text(result: ToolExecutionResult): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
/** Mount code for a listener plugin: logs on every `tools/change`. */
|
||||
export const LISTENER_CODE = `
|
||||
return {
|
||||
name: 'change-logger',
|
||||
apply(ctx) {
|
||||
ctx.on('tools/change', () => console.log('tools changed'))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
|
||||
export const REVERSE_TOOL_CODE = `
|
||||
return {
|
||||
name: 'reverse-text',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'reverse_text',
|
||||
description: 'Reverse a string.',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: args.text.split('').reverse().join('') }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code providing a `greeter` service other mounts can inject. */
|
||||
export const PROVIDER_CODE = `
|
||||
return {
|
||||
name: 'greeter-provider',
|
||||
apply(ctx) {
|
||||
ctx.provide('greeter', { greet: (name) => 'hi ' + name })
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code consuming the `greeter` service through inject, exposing it as a tool. */
|
||||
export const CONSUMER_CODE = `
|
||||
return {
|
||||
name: 'greeter-consumer',
|
||||
inject: ['greeter', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'greet',
|
||||
description: 'Greet someone via the greeter service.',
|
||||
parameters: { name: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: ctx.greeter.greet(args.name) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** A registrable no-op tool the tests use to trigger a real `tools/change`. */
|
||||
export function dummyTool(name: string): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: 'test trigger',
|
||||
parameters: { type: 'object' as const, properties: {} },
|
||||
async execute(): Promise<[]> {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
123
packages/cordis/tool-cordis/tests/inspect.spec.ts
Normal file
123
packages/cordis/tool-cordis/tests/inspect.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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 { 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).
|
||||
*/
|
||||
|
||||
describe('cordis_inspect', () => {
|
||||
it('reports all six sections by default', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_inspect', {})
|
||||
expect(result.isError).toBe(false)
|
||||
const report = text(result)
|
||||
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.
|
||||
expect(report).toContain('- tools (provided by ToolRegistry)')
|
||||
expect(report).toMatch(/tool-cordis \[active\]/)
|
||||
expect(report).toMatch(/cordis-dynamic \[active\]/)
|
||||
expect(report).toContain('- cordis_mount')
|
||||
expect(report).toContain('(no dynamic plugins mounted)')
|
||||
})
|
||||
|
||||
it('limits the report to one section via `what`', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_inspect', { what: 'tools' })
|
||||
const report = text(result)
|
||||
expect(report).toContain('## tools')
|
||||
expect(report).not.toContain('## services')
|
||||
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 () => {
|
||||
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\]/)
|
||||
})
|
||||
|
||||
it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
|
||||
// Live catalogued services render summary + signatures.
|
||||
expect(report).toContain('- tools — ')
|
||||
expect(report).toContain('register(definition: ToolDefinition)')
|
||||
expect(report).toContain('- systemPrompt — ')
|
||||
// Catalogued services with no live provider are listed tersely.
|
||||
expect(report).toMatch(/not running \(loadable services with no live provider\): .*bash/)
|
||||
// The type shapes the LIVE signatures reference follow (closure over the
|
||||
// generated TYPE_API — a consumer can see field types, not just names).
|
||||
expect(report).toContain('type shapes (referenced by the signatures above')
|
||||
expect(report).toContain('export interface ToolExecution')
|
||||
// A type only reachable through a NOT-live service (e.g. bash) is scoped out.
|
||||
expect(report).not.toContain('export interface BashRunResult')
|
||||
// The inherited ctx surface closes the section.
|
||||
expect(report).toContain('inherited ctx API:')
|
||||
expect(report).toContain('- ctx.effect — ')
|
||||
})
|
||||
|
||||
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'events' }))
|
||||
expect(report).toContain('- tools/change [emit]')
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toMatch(/'agent\/status'\(/)
|
||||
expect(report).toContain('returning without next() vetoes the chain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('inspect renderers (direct)', () => {
|
||||
it('describeServices reports an empty store as such, and labels a non-active provider', () => {
|
||||
const empty = { reflect: { store: {} } } as unknown as Context
|
||||
expect(describeServices(empty)).toEqual(['(no services provided)'])
|
||||
|
||||
const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber
|
||||
const store: Record<symbol, unknown> = {}
|
||||
store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber }
|
||||
const ctx = { reflect: { store } } as unknown as Context
|
||||
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('describeApi omits the not-running line and type shapes when nothing applies', async () => {
|
||||
const ctx = await setup()
|
||||
const lines = describeApi(ctx, [{ key: 'tools', summary: 'The registry.', methods: ['register(x): void'] }], [], [])
|
||||
expect(lines[0]).toBe('- tools — The registry.')
|
||||
expect(lines[1]).toBe(' register(x): void')
|
||||
expect(lines.join('\n')).not.toContain('not running')
|
||||
expect(lines.join('\n')).not.toContain('type shapes')
|
||||
})
|
||||
|
||||
it('describeEvents renders an empty catalog as just the waterfall caution', () => {
|
||||
expect(describeEvents([])).toEqual([
|
||||
'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.',
|
||||
])
|
||||
})
|
||||
})
|
||||
74
packages/cordis/tool-cordis/tests/integration.spec.ts
Normal file
74
packages/cordis/tool-cordis/tests/integration.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as ToolCordis from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { REVERSE_TOOL_CODE } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model mounts a plugin that registers
|
||||
* a NEW tool, calls that tool on the very next step (tool schemas are
|
||||
* reassembled per step — the real loop proves the self-extension contract),
|
||||
* and unmounts it again. Only the model is mocked; the sandbox, the fiber
|
||||
* tree, and the session log are real.
|
||||
*/
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(ToolCordis)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('cordis tools through the agent loop', () => {
|
||||
it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
|
||||
toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
|
||||
toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
|
||||
textResponse('Done.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
|
||||
expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
|
||||
|
||||
const results = log.filter(event => event.type === 'tool/result')
|
||||
expect(results.map(event => event.data.isError)).toEqual([false, false, false])
|
||||
const reversed = results[1]!.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
expect(reversed).toBe('ssenrah')
|
||||
|
||||
// After the unmount the self-made tool is gone from the registry.
|
||||
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
409
packages/cordis/tool-cordis/tests/mount.spec.ts
Normal file
409
packages/cordis/tool-cordis/tests/mount.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { syntaxErrorContext } from '../src/sandbox.ts'
|
||||
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The `cordis_mount` success/failure family: real plugins land on a genuine
|
||||
* cordis fiber tree, their registrations are observable through the real
|
||||
* registry/event bus, and every rejection path teaches the fix.
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('cordis_mount', () => {
|
||||
it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
|
||||
|
||||
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
|
||||
ctx.tools.register(dummyTool('trigger_a'))
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tools changed')
|
||||
})
|
||||
|
||||
it('mounts a bare-function plugin as <anonymous>, and a named function under its name', async () => {
|
||||
const ctx = await setup()
|
||||
const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
|
||||
expect(anonymous.isError).toBe(false)
|
||||
expect(text(anonymous)).toContain('plugin "<anonymous>"')
|
||||
const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' })
|
||||
expect(text(named)).toContain('plugin "watcher"')
|
||||
})
|
||||
|
||||
it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
|
||||
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
|
||||
expect(reversed.isError).toBe(false)
|
||||
expect(text(reversed)).toBe('ssenrah')
|
||||
})
|
||||
|
||||
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
|
||||
// The model's execute builds its content blocks INSIDE the vm, where
|
||||
// Object.prototype is a different object — dsh-session's isJsonValue (the
|
||||
// gate every `tool/result` append runs through) compares prototype
|
||||
// IDENTITY, so a raw foreign-realm result would error the whole turn the
|
||||
// first time the self-made tool runs. harness.defineTool round-trips the
|
||||
// return into host-realm JSON before it reaches the registry.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
|
||||
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects JSON Schema passed to harness.defineTool with the SchemaSpec teaching error', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'bad-json-schema-tool',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'bad_json_schema_tool',
|
||||
description: 'bad',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { text: { type: 'string' } },
|
||||
required: ['text'],
|
||||
},
|
||||
async execute() { return [{ type: 'text', text: 'bad' }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('harness.defineTool parameters use the SchemaSpec DSL')
|
||||
expect(ctx.tools.get('bad_json_schema_tool')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['parameters: 42', 'must be a SchemaSpec object'],
|
||||
['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'],
|
||||
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type'],
|
||||
['parameters: { text: { type: \'string\', required: false } }', 'parameters.text.required must be true when present'],
|
||||
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'],
|
||||
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'],
|
||||
])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'bad-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'bad_schema_tool',
|
||||
description: 'bad',
|
||||
${parameters},
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(message)
|
||||
})
|
||||
|
||||
it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'nested-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'nested_schema_tool',
|
||||
description: 'nested',
|
||||
parameters: {
|
||||
item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
async execute(args) { return [{ type: 'text', text: args.item.label }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const echoed = await call(ctx, 'nested_schema_tool', { item: { label: 'ok' }, tags: ['a'] })
|
||||
expect(text(echoed)).toBe('ok')
|
||||
})
|
||||
|
||||
it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'raw-register',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
ctx.tools.register({
|
||||
name: 'raw_dynamic_tool',
|
||||
description: 'raw',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
|
||||
expect(ctx.tools.get('raw_dynamic_tool')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards the registry reached through ctx.get(\'tools\') identically', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'raw-register-get',
|
||||
apply(ctx) {
|
||||
const sp = ctx.get('systemPrompt')
|
||||
console.log('systemPrompt is', typeof sp)
|
||||
ctx.get('tools').register({ name: 'raw_via_get', description: 'raw', parameters: {}, async execute() { return [] } })
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
|
||||
expect(ctx.tools.get('raw_via_get')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes non-register registry members through the guard with correct binding', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'schema-reader',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount'))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'sees', 3, 'tools; mount is', 'object')
|
||||
})
|
||||
|
||||
it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('state: pending')
|
||||
expect(text(result)).toContain('waiting for service(s): no-such-service')
|
||||
// Unmounting a pending mount works like any other.
|
||||
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(unmounted.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects code that throws, leaving nothing mounted', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('boom in sandbox')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
|
||||
})
|
||||
|
||||
it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
|
||||
const ctx = await setup()
|
||||
const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' })
|
||||
expect(primitive.isError).toBe(true)
|
||||
expect(text(primitive)).toContain('plain-string-throw')
|
||||
const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' })
|
||||
expect(nullish.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects code that does not return a plugin', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must `return` a plugin')
|
||||
})
|
||||
|
||||
it('answers a missing return with the two valid plugin forms', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('did you forget `return`?')
|
||||
})
|
||||
|
||||
it('disposes a plugin whose apply throws, and reports the error', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('apply exploded')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
|
||||
})
|
||||
|
||||
it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'usurper',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'cordis_mount',
|
||||
description: 'dup',
|
||||
parameters: {},
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('already registered')
|
||||
expect(text(result)).toContain('first cordis_unmount')
|
||||
// The original cordis_mount still dispatches — the failed fiber is gone.
|
||||
const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
expect(retry.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates sandbox globals: no process/require, and globalThis writes do not leak to the host', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
globalThis.__cordis_tool_leak = 'leaked'
|
||||
return { name: 'probe-' + typeof process + '-' + typeof require, apply(ctx) {} }
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plugin "probe-undefined-undefined"')
|
||||
expect((globalThis as Record<string, unknown>).__cordis_tool_leak).toBeUndefined()
|
||||
})
|
||||
|
||||
it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
console.warn('warned')
|
||||
console.error('errored')
|
||||
const round = atob(btoa('hi'))
|
||||
const bytes = new TextEncoder().encode(round)
|
||||
return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.fiber) } }
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plugin "codec-hi"')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'warned')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'applied', 'object')
|
||||
expect(error).toHaveBeenCalledWith('[cordis:dyn-1]', 'errored')
|
||||
})
|
||||
|
||||
it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'ts\' as const, apply(ctx) {} }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('plain JavaScript, not TypeScript')
|
||||
})
|
||||
|
||||
it('surfaces the offending line + caret and the bracket-balance hint on a syntax error', async () => {
|
||||
const ctx = await setup()
|
||||
// The canonical model mistake: closing the returned object with `});` as
|
||||
// if it were a callback argument. The word "as" in a STRING elsewhere must
|
||||
// not trigger the TypeScript hint — the heuristic reads the failing line.
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
const message = text(result)
|
||||
expect(message).toContain('failed to parse')
|
||||
expect(message).toContain('});')
|
||||
expect(message).toContain('^')
|
||||
expect(message).toContain('BODY of an async function')
|
||||
expect(message).not.toContain('TypeScript')
|
||||
})
|
||||
|
||||
it('syntaxErrorContext falls back to String(error) when the stack has no vm prelude', () => {
|
||||
const doctored = new SyntaxError('boom')
|
||||
delete (doctored as { stack?: string }).stack
|
||||
expect(syntaxErrorContext(doctored)).toBe('SyntaxError: boom')
|
||||
const plain = new SyntaxError('bang')
|
||||
plain.stack = 'not-a-vm-stack'
|
||||
expect(syntaxErrorContext(plain)).toBe('SyntaxError: bang')
|
||||
})
|
||||
|
||||
it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('failed to parse')
|
||||
expect(text(result)).toContain('user-crafted')
|
||||
})
|
||||
|
||||
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
|
||||
const ctx = await setup({ vmTimeoutMs: 50 })
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(/timed? ?out/i)
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
|
||||
})
|
||||
|
||||
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
|
||||
// The args a tool's execute receives are HOST-realm objects; without the
|
||||
// dual-realm Symbol.hasInstance prelude, `args.items instanceof Array` in
|
||||
// sandbox code is silently false. The patch lives on the vm realm's own
|
||||
// constructors only — the host realm's must stay pristine.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'probe-instanceof',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'probe_instanceof',
|
||||
description: 'report instanceof checks across realms',
|
||||
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
|
||||
async execute(args) {
|
||||
const checks = {
|
||||
hostArray: args.items instanceof Array,
|
||||
hostObject: args instanceof Object,
|
||||
vmArray: [] instanceof Array,
|
||||
vmObject: ({}) instanceof Object,
|
||||
}
|
||||
return [{ type: 'text', text: JSON.stringify(checks) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const probed = await call(ctx, 'probe_instanceof', { items: ['a'] })
|
||||
expect(probed.isError).toBe(false)
|
||||
expect(JSON.parse(text(probed))).toEqual({ hostArray: true, hostObject: true, vmArray: true, vmObject: true })
|
||||
// The host realm's constructors keep their default instanceof: no own
|
||||
// Symbol.hasInstance was added to them.
|
||||
expect(Object.getOwnPropertySymbols(Object)).not.toContain(Symbol.hasInstance)
|
||||
expect(Object.getOwnPropertySymbols(Array)).not.toContain(Symbol.hasInstance)
|
||||
})
|
||||
})
|
||||
41
packages/cordis/tool-cordis/tests/present.spec.ts
Normal file
41
packages/cordis/tool-cordis/tests/present.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
|
||||
import { setup } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Render-intent presenters: pure functions of the call args (no I/O, no
|
||||
* session state — they run on replay too), wired onto the registered tools.
|
||||
*/
|
||||
|
||||
describe('presenters', () => {
|
||||
it('cordis_inspect renders a generic read card titled with the section', () => {
|
||||
expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' })
|
||||
expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' })
|
||||
})
|
||||
|
||||
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
|
||||
expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: 'Mount plugin into live cordis runtime',
|
||||
rawInput: { code: 'return (ctx) => {}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('cordis_unmount renders a generic delete card titled with the id', () => {
|
||||
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' })
|
||||
})
|
||||
|
||||
it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'tools' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: 'Inspect cordis runtime: tools',
|
||||
})
|
||||
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
|
||||
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
|
||||
// Soft validation: presenter args that fail the schema render as no card, never a throw.
|
||||
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
49
packages/cordis/tool-cordis/tests/tool-cordis.spec.ts
Normal file
49
packages/cordis/tool-cordis/tests/tool-cordis.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { setup } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Export-shape and registration surface: the namespace-plugin contract the
|
||||
* real Loader path depends on, the registered tool set, and the Config
|
||||
* validator's defaults and rejections.
|
||||
*/
|
||||
|
||||
describe('export shape', () => {
|
||||
it('has no default export, and survives the real Loader unwrapExports', () => {
|
||||
// A stray `export default` would make `unwrapExports` (`exports.default ??
|
||||
// exports`) collapse the module to the bare function and DROP `inject`,
|
||||
// crashing at real load (docs/postmortem/0001). Assert directly AND through
|
||||
// the real unwrap so adding `export default apply` fails here.
|
||||
expect('default' in tool).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tool)
|
||||
expect(unwrapped.name).toBe('tool-cordis')
|
||||
expect(unwrapped.inject).toEqual(['tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(typeof unwrapped.Config).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('registers the three cordis tools with the documented schemas', async () => {
|
||||
const ctx = await setup()
|
||||
const names = ctx.tools.schemas().map(schema => schema.name)
|
||||
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
|
||||
const props = (inspect.parameters as { properties: Record<string, { enum?: string[] }> }).properties
|
||||
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Config', () => {
|
||||
it('defaults vmTimeoutMs to 5000', () => {
|
||||
expect(new tool.Config()).toEqual({ vmTimeoutMs: 5000 })
|
||||
})
|
||||
|
||||
it('rejects a non-positive vmTimeoutMs at validation time (misconfiguration fails loud)', () => {
|
||||
expect(() => new tool.Config({ vmTimeoutMs: 0 })).toThrow()
|
||||
expect(() => new tool.Config({ vmTimeoutMs: -1 })).toThrow()
|
||||
})
|
||||
})
|
||||
82
packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts
Normal file
82
packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user