• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONSUMER_CODE, CONTENT_OUTPUT_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('is running')
|
||||
|
||||
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('is running')
|
||||
|
||||
// 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('is pending')
|
||||
expect(text(consumer)).toContain('missing services: greeter')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).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: 'temporary' }))
|
||||
expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until unmounted or DSH restarts')
|
||||
})
|
||||
|
||||
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: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]')
|
||||
})
|
||||
|
||||
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: 'temporary' }))
|
||||
expect(report).toContain('Temporary Plugin 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: 'temporary' }))
|
||||
expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until unmounted or DSH restarts')
|
||||
|
||||
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('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'answer-provider',
|
||||
apply(ctx) {
|
||||
ctx.provide('answer', 42)
|
||||
ctx.provide('nothing', null)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(provider.isError).toBe(false)
|
||||
|
||||
const consumer = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'answer-consumer',
|
||||
inject: ['answer', 'nothing', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'answer',
|
||||
description: 'Read the provided primitive services.',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('is running')
|
||||
expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
|
||||
})
|
||||
|
||||
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: 'temporary' }))).toContain('Temporary Plugin dyn-1: greeter-provider [running]')
|
||||
})
|
||||
})
|
||||
@@ -2,34 +2,81 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { Config as RunnerConfig } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* Shared spec helpers: a real `SystemPrompt` + `ToolRuntime` + 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.
|
||||
* Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer + the
|
||||
* dynamic runner + this toolset (only the model and the browser are absent — the
|
||||
* code strings below stand in for what the model would write, and no gateway is
|
||||
* composed, so a browser half has nowhere to go).
|
||||
*
|
||||
* Every dynamic-package tool is session-scoped, so calls carry a stand-in agent.
|
||||
*/
|
||||
|
||||
/** Mount the plugin on a fresh context with a real ToolRuntime and the timer service. */
|
||||
export async function setup(config?: tool.Config): Promise<Context> {
|
||||
/** The session every spec call runs as. */
|
||||
export const AGENT = { id: 'S-spec' as SessionId } as Agent
|
||||
|
||||
/** Mount the toolset on a fresh context with a real ToolRegistry, the timer service, and the runner. */
|
||||
export async function setup(config?: RunnerConfig): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(tool, config)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(CordisHostRunner, config)
|
||||
await ctx.plugin(tool)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* The same composition plus a stand-in browser: an `apiProxy` whose broadcast
|
||||
* answers a run request by walking the runner's own verbs, exactly as the real
|
||||
* client half does. Without it a package with a browser half can only ever be
|
||||
* refused, so the tool's success reporting for that shape stays untested.
|
||||
* @param waitingFor - services the answering page reports its half parked on.
|
||||
* @returns the mounted context.
|
||||
*/
|
||||
export async function setupWithBrowser(waitingFor?: readonly string[]): Promise<Context> {
|
||||
const ctx = await setup()
|
||||
const runner = ctx.dynamicCordisRunner
|
||||
// The fake browser subscribes the way a real page does — to the forwarded Host
|
||||
// event, not to a transport frame — and answers by walking the same verbs.
|
||||
ctx.on('cordis/request-run', (request) => {
|
||||
const { requestId, pluginId, packageId, mode } = request
|
||||
queueMicrotask(() => {
|
||||
void (async (): Promise<void> => {
|
||||
const half = await runner.runHostHalf(AGENT, pluginId, packageId, mode, requestId, false)
|
||||
if (!half.ok) return
|
||||
const source = runner.getClientCode(AGENT, pluginId, half.pluginRunId)
|
||||
await runner.resolveRequestRun(requestId, {
|
||||
ok: true,
|
||||
pluginRunId: source.pluginRunId,
|
||||
...waitingFor === undefined ? {} : { waitingFor },
|
||||
})
|
||||
})()
|
||||
})
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
|
||||
/** Execute a registered tool through the real registry pipeline. */
|
||||
/** Execute a registered tool through the real registry pipeline, as the spec agent. */
|
||||
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: AGENT,
|
||||
})
|
||||
}
|
||||
|
||||
/** Concatenated text blocks of one tool result. */
|
||||
@@ -37,7 +84,22 @@ 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`. */
|
||||
/** Define one host-half package and run it, returning its minted id. */
|
||||
export async function defineAndRun(ctx: Context, code: string, name = 'spec-package'): Promise<string> {
|
||||
const defined = await call(ctx, 'cordis_define', {
|
||||
plugin: { kind: 'new', idPrefix: 'spec' },
|
||||
name,
|
||||
purpose: 'spec fixture',
|
||||
code: { host: code },
|
||||
})
|
||||
if (defined.isError) throw new Error(`define failed: ${text(defined)}`)
|
||||
const { pluginId, packageId } = defined.value as { pluginId: string; packageId: string }
|
||||
const ran = await call(ctx, 'cordis_run', { pluginId, packageId, mode: 'run' })
|
||||
if (ran.isError) throw new Error(`run failed: ${text(ran)}`)
|
||||
return pluginId
|
||||
}
|
||||
|
||||
/** Host-half code for a listener plugin: logs on every `tools/change`. */
|
||||
export const LISTENER_CODE = `
|
||||
return {
|
||||
name: 'change-logger',
|
||||
@@ -47,14 +109,7 @@ export const LISTENER_CODE = `
|
||||
}
|
||||
`
|
||||
|
||||
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */
|
||||
export const CONTENT_OUTPUT_CODE = `
|
||||
output: {
|
||||
schema: { type: 'array', items: { type: 'json' } },
|
||||
render(_args, value) { return value },
|
||||
},`
|
||||
|
||||
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
|
||||
/** Host-half code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
|
||||
export const REVERSE_TOOL_CODE = `
|
||||
return {
|
||||
name: 'reverse-text',
|
||||
@@ -78,7 +133,7 @@ export const REVERSE_TOOL_CODE = `
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code providing a `greeter` service other mounts can inject. */
|
||||
/** Host-half code providing a `greeter` service other packages can inject. */
|
||||
export const PROVIDER_CODE = `
|
||||
return {
|
||||
name: 'greeter-provider',
|
||||
@@ -88,7 +143,7 @@ export const PROVIDER_CODE = `
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code consuming the `greeter` service through inject, exposing it as a tool. */
|
||||
/** Host-half code consuming the `greeter` service through inject, exposing it as a tool. */
|
||||
export const CONSUMER_CODE = `
|
||||
return {
|
||||
name: 'greeter-consumer',
|
||||
@@ -111,16 +166,3 @@ export const CONSUMER_CODE = `
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** 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: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
async execute(): Promise<null> {
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { FiberState } from '../src/fiber-state.ts'
|
||||
import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts'
|
||||
import { call, LISTENER_CODE, setup, text } from './helpers.ts'
|
||||
import {
|
||||
describeApi, describeClient, describeDynamic, describeEvents, describePlugins, describeServices,
|
||||
} from '../src/inspect.ts'
|
||||
import type { ClientSlotEntry } from '../src/client-catalog.ts'
|
||||
import { call, defineAndRun, LISTENER_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/** A single seat the shipped composition already occupies. */
|
||||
const SEAT: ClientSlotEntry = {
|
||||
key: 'demo.seat',
|
||||
kind: 'single',
|
||||
scope: 'root',
|
||||
summary: 'A seat.',
|
||||
doc: 'A seat.',
|
||||
registerOptions: [],
|
||||
ownerProps: [],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: ['useSessions: Hook'],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'the runtime itself (built in; always present)',
|
||||
occupants: ['client-demo DemoSeat'],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {}',
|
||||
// A hypothetical package: naming a real one would tie this fixture to a
|
||||
// surface it does not describe, and the real catalog carries the pointer.
|
||||
source: 'a demo client package, slots.ts:1',
|
||||
}
|
||||
|
||||
/** An empty list seat: the additive-with-no-occupant wording and the detail block. */
|
||||
const LIST_SEAT: ClientSlotEntry = {
|
||||
...SEAT,
|
||||
key: 'demo.list',
|
||||
kind: 'list',
|
||||
summary: 'A list.',
|
||||
doc: 'A list.',
|
||||
registerOptions: [{ name: 'id', requirement: 'required', type: 'string', doc: 'Your cell key.' }],
|
||||
declaredBy: "an entry in 'demo.parent' (client-demo), so it exists while that entry is mounted",
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: "ctx.slots.register({ name: 'demo.list', id: 'mine' }, C)",
|
||||
}
|
||||
|
||||
/** An occupied list seat: additive, but the report still names who is already there. */
|
||||
const LIST_SEAT_OCCUPIED: ClientSlotEntry = {
|
||||
...LIST_SEAT,
|
||||
key: 'demo.list.busy',
|
||||
occupants: ["client-demo DemoRow id 'shipped'"],
|
||||
}
|
||||
|
||||
/** A keyed seat carrying every optional field, so each one's presence branch renders. */
|
||||
const KEYED_SEAT: ClientSlotEntry = {
|
||||
...SEAT,
|
||||
key: 'demo.keyed',
|
||||
kind: 'keyed',
|
||||
summary: 'A keyed seat.',
|
||||
doc: 'A keyed seat.',
|
||||
registerOptions: [{ name: 'key', requirement: 'required', type: 'string', doc: 'Your cell key.' }],
|
||||
ownerProps: ['export interface KeyedOwnerProps {\n block: ToolCallBlock\n}'],
|
||||
ownerPropsReferences: ['ToolCallBlock'],
|
||||
keyDomain: 'open: any string the owner dispatches, already taken: bash',
|
||||
hookContext: 'ChatNodeContext',
|
||||
slotInject: 'ChatNodeInjected',
|
||||
occupants: ["client-demo DemoView key 'bash'"],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cordis_inspect` sections: rendered against the real runtime through the
|
||||
* The `cordis_runtime_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, same-named sibling fibers, a fully-live catalog).
|
||||
*/
|
||||
|
||||
describe('cordis_inspect', () => {
|
||||
it('reports all six sections by default', async () => {
|
||||
describe('cordis_runtime_inspect', () => {
|
||||
it('reports all seven sections by default', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_inspect', {})
|
||||
const result = await call(ctx, 'cordis_runtime_inspect', {})
|
||||
expect(result.isError).toBe(false)
|
||||
const report = text(result)
|
||||
if (result.isError) throw new Error('expected cordis_inspect success')
|
||||
if (result.isError) throw new Error('expected cordis_runtime_inspect success')
|
||||
expect(result.value).toBe(report)
|
||||
for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) {
|
||||
for (const heading of ['services', 'plugins', 'tools', 'Dynamic Packages', 'api', 'events', 'client']) {
|
||||
expect(report).toContain(`## ${heading}`)
|
||||
}
|
||||
// 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 ToolRuntime)')
|
||||
expect(report).toContain('- tools (provided by ToolRegistry)')
|
||||
expect(report).toContain('- tool-cordis [active]')
|
||||
expect(report).toContain('- cordis-dynamic [active]')
|
||||
expect(report).toContain('- cordis_mount')
|
||||
expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.')
|
||||
expect(report).toContain('- cordis_define')
|
||||
expect(report).toContain('No dynamic packages are defined in this session.')
|
||||
})
|
||||
|
||||
it('limits the report to one section via `what`', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_inspect', { what: 'tools' })
|
||||
const result = await call(ctx, 'cordis_runtime_inspect', { what: 'tools' })
|
||||
const report = text(result)
|
||||
expect(report).toContain('## tools')
|
||||
expect(report).not.toContain('## services')
|
||||
expect(report).not.toContain('## plugins')
|
||||
})
|
||||
|
||||
it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => {
|
||||
it('shows a running dynamic package in its exact 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('## Temporary Plugins')
|
||||
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts')
|
||||
await defineAndRun(ctx, LISTENER_CODE, 'logger')
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', {}))
|
||||
expect(report).toContain('## Dynamic Packages')
|
||||
expect(report).toContain('- dyn-1: logger [running, rev 1] (host) — spec fixture; provides: none; waiting for: none')
|
||||
// The group fiber and the package's own plugin are both live in the flat list.
|
||||
expect(report).toContain('- cordis-dynamic [active]')
|
||||
expect(report).toContain('- change-logger [active]')
|
||||
})
|
||||
|
||||
it('shows a defined-but-not-running package, and the invoke methods a running one registered', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_define', { name: 'idle', purpose: 'waits to be started', code: 'return () => {}' })
|
||||
await defineAndRun(ctx, 'harness.handle(\'ping\', async () => \'pong\')\nreturn () => {}', 'handler')
|
||||
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' }))
|
||||
|
||||
expect(report).toContain('- dyn-1: idle [defined, not running] (host) — waits to be started')
|
||||
expect(report).toContain('host methods: ping')
|
||||
})
|
||||
|
||||
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' }))
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'api' }))
|
||||
// Live catalogued services render summary + signatures.
|
||||
expect(report).toContain('- tools — ')
|
||||
expect(report).toContain('- tools — Tool registry and execution pipeline.')
|
||||
expect(report).toContain('register(definition: ToolDefinition)')
|
||||
expect(report).toContain('- systemPrompt — ')
|
||||
// The projection carries public METHODS only: state and symbol-keyed seams
|
||||
// between plugins are not calls a package can make.
|
||||
expect(report).not.toContain('store: Map<string, ToolDefinition>')
|
||||
expect(report).not.toContain('TOOL_REGISTRY_SCHEDULER')
|
||||
// Catalogued services with no live provider are listed tersely.
|
||||
expect(report).toMatch(/not running \(loadable services with no live provider\): .*shell/)
|
||||
// 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).toMatch(/not running \(loadable services with no live provider\): .*bash/)
|
||||
// The type shapes the LIVE signatures reference follow, so a consumer can see
|
||||
// field types rather than only names.
|
||||
expect(report).toContain('type shapes (referenced by the signatures above')
|
||||
expect(report).toContain('export interface ToolExecution')
|
||||
expect(report).toContain('export class Session')
|
||||
expect(report).toContain('export interface SessionSurface')
|
||||
expect(report).toContain('export interface ToolDefinition')
|
||||
// A type only reachable through a NOT-live service (e.g. bash) is scoped out.
|
||||
expect(report).not.toContain('export interface ShellRunResult')
|
||||
expect(report).not.toContain('export interface BashRunResult')
|
||||
// The inherited ctx API closes the section.
|
||||
expect(report).toContain('inherited ctx API:')
|
||||
expect(report).toContain('- ctx.effect — ')
|
||||
@@ -76,13 +154,13 @@ describe('cordis_inspect', () => {
|
||||
|
||||
it('adds original method JSDoc only for an exact live api name', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'api', name: 'tools' }))
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'tools' }))
|
||||
expect(report).toContain('## api')
|
||||
expect(report).toContain('- tools — Tool registry and execution pipeline.')
|
||||
expect(report).toContain('/**')
|
||||
expect(report).toContain('Register globally or in the calling agent scope.')
|
||||
expect(report).toContain('@param definition - tool schema, execution, and optional finalization/presentation callbacks')
|
||||
expect(report).toContain('@returns the exact disposer')
|
||||
expect(report).toContain('@param definition - tool schema, execution, and optional')
|
||||
expect(report).toContain('@returns the exact disposer that unregisters the tool.')
|
||||
expect(report).toContain('register(definition: ToolDefinition)')
|
||||
expect(report).toContain('type shapes (referenced by the signatures above')
|
||||
expect(report).not.toContain('not running (loadable services')
|
||||
@@ -91,10 +169,10 @@ describe('cordis_inspect', () => {
|
||||
|
||||
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' }))
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'events' }))
|
||||
expect(report).toContain('- tools/change [emit]')
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toMatch(/'agent\/status'\(/)
|
||||
expect(report).toMatch(/'tools\/change'\(/)
|
||||
expect(report).toContain('returning without next() short-circuits the chain')
|
||||
expect(report).not.toContain('/**')
|
||||
expect(report).not.toContain('@mode waterfall')
|
||||
@@ -102,7 +180,7 @@ describe('cordis_inspect', () => {
|
||||
|
||||
it('adds original event JSDoc only for an exact event name', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'events', name: 'tools/pre-execute' }))
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'events', name: 'tools/pre-execute' }))
|
||||
expect(report).toContain('## events')
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toContain('/**')
|
||||
@@ -114,19 +192,19 @@ describe('cordis_inspect', () => {
|
||||
|
||||
it('fails loud for incompatible, unknown, and non-running names', async () => {
|
||||
const ctx = await setup()
|
||||
const incompatible = await call(ctx, 'cordis_inspect', { what: 'tools', name: 'tools' })
|
||||
const incompatible = await call(ctx, 'cordis_runtime_inspect', { what: 'tools', name: 'tools' })
|
||||
expect(incompatible.isError).toBe(true)
|
||||
expect(text(incompatible)).toContain('name is valid only with what:"api" or what:"events"')
|
||||
expect(text(incompatible)).toContain('name is valid only with what:"api", what:"events", or what:"client"')
|
||||
|
||||
const unknownService = await call(ctx, 'cordis_inspect', { what: 'api', name: 'not-a-service' })
|
||||
const unknownService = await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'not-a-service' })
|
||||
expect(unknownService.isError).toBe(true)
|
||||
expect(text(unknownService)).toContain('no catalogued service named "not-a-service"')
|
||||
|
||||
const nonRunning = await call(ctx, 'cordis_inspect', { what: 'api', name: 'shell' })
|
||||
const nonRunning = await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'bash' })
|
||||
expect(nonRunning.isError).toBe(true)
|
||||
expect(text(nonRunning)).toContain('catalogued service "shell" is not running')
|
||||
expect(text(nonRunning)).toContain('catalogued service "bash" is not running')
|
||||
|
||||
const unknownEvent = await call(ctx, 'cordis_inspect', { what: 'events', name: 'not/an-event' })
|
||||
const unknownEvent = await call(ctx, 'cordis_runtime_inspect', { what: 'events', name: 'not/an-event' })
|
||||
expect(unknownEvent.isError).toBe(true)
|
||||
expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"')
|
||||
})
|
||||
@@ -135,13 +213,16 @@ describe('cordis_inspect', () => {
|
||||
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)'])
|
||||
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)'])
|
||||
const lines = describeServices(ctx, [])
|
||||
// A service the catalog does not cover still appears, with its owner and the
|
||||
// non-active lifecycle label; only the summary is missing.
|
||||
expect(lines).toEqual(['- thing (provided by half-loaded, pending)'])
|
||||
})
|
||||
|
||||
it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
|
||||
@@ -161,17 +242,156 @@ describe('inspect renderers (direct)', () => {
|
||||
const lines = describeApi(ctx, [{
|
||||
key: 'tools',
|
||||
summary: 'The registry.',
|
||||
methods: [{ signature: 'register(x): void', jsDoc: '/** Register x. */' }],
|
||||
}], [], [])
|
||||
description: 'The registry.',
|
||||
methods: [{
|
||||
signature: 'register(x): void',
|
||||
description: 'Register x.',
|
||||
parameters: [{ name: 'x', description: 'Value to register.' }],
|
||||
}],
|
||||
}], undefined, [], [])
|
||||
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('expands the shapes a service names transitively, listing each one once', async () => {
|
||||
const ctx = await setup()
|
||||
const lines = describeApi(ctx, [{
|
||||
key: 'tools',
|
||||
summary: 'The registry.',
|
||||
description: 'The registry.',
|
||||
methods: [{
|
||||
signature: 'register(definition: ToolDefinition): void',
|
||||
description: '',
|
||||
parameters: [],
|
||||
}],
|
||||
}], 'tools', [], [
|
||||
{ name: 'ToolDefinition', declaration: 'export interface ToolDefinition {\n schema: ToolSchema\n}' },
|
||||
{ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n owner: ToolDefinition\n}' },
|
||||
]).join('\n')
|
||||
// The signature names one shape, that shape names the second, and the two
|
||||
// reference each other back: a reader gets both, each exactly once.
|
||||
expect(lines.match(/export interface ToolDefinition/g)).toHaveLength(1)
|
||||
expect(lines.match(/export interface ToolSchema/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
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() short-circuits the chain.',
|
||||
])
|
||||
})
|
||||
|
||||
it('describeApi reports a live service with no catalogued signature as still injectable', async () => {
|
||||
const ctx = await setup()
|
||||
const lines = describeApi(ctx, []).join('\n')
|
||||
// The framework tier is exactly this case: reachable through inject, but
|
||||
// with no projected signature — the report must not read as "absent".
|
||||
expect(lines).toContain('running, but this catalog has no signature for it')
|
||||
expect(lines).toContain('still reaches it')
|
||||
})
|
||||
|
||||
it('describeClient lists every seat with what registering there costs', () => {
|
||||
const lines = describeClient([SEAT, LIST_SEAT, LIST_SEAT_OCCUPIED], ['one rule']).join('\n')
|
||||
expect(lines).toContain('- demo.seat [single, root] — A seat.')
|
||||
expect(lines).toContain('OCCUPIED — registering here REPLACES: client-demo DemoSeat')
|
||||
expect(lines).toContain('- demo.list [list, root]')
|
||||
expect(lines).toContain('additive (no shipped entries)')
|
||||
// Additive does not mean empty: an id already in use is still a takeover.
|
||||
expect(lines).toContain("additive (beside: client-demo DemoRow id 'shipped')")
|
||||
expect(lines).toContain('- one rule')
|
||||
// The compact listing must not spend context on per-seat detail.
|
||||
expect(lines).not.toContain('register options besides name:')
|
||||
expect(lines).not.toContain('framework props for this scope:')
|
||||
})
|
||||
|
||||
it('describeClient expands the optional contract fields only where a seat has them', () => {
|
||||
const keyed = describeClient([KEYED_SEAT], [], 'demo.keyed').join('\n')
|
||||
expect(keyed).toContain('key domain: open: any string the owner dispatches, already taken: bash')
|
||||
expect(keyed).toContain('owner props (the shapes the owner passes down):')
|
||||
// Owner props expand one level; referenced shapes are named, not inlined.
|
||||
expect(keyed).toContain('shapes those fields reference, not expanded here: ToolCallBlock')
|
||||
expect(keyed).toContain('slot-level inject face every entry receives: ChatNodeInjected')
|
||||
expect(keyed).toContain('per-render-site hook context: ChatNodeContext')
|
||||
expect(keyed).toContain('key (required, string)')
|
||||
|
||||
// A seat without them says nothing about them.
|
||||
const plain = describeClient([SEAT], [], 'demo.seat').join('\n')
|
||||
expect(plain).toContain('owner props: none')
|
||||
expect(plain).toContain('register options besides name: none')
|
||||
expect(plain).not.toContain('key domain:')
|
||||
expect(plain).not.toContain('slot-level inject face')
|
||||
expect(plain).not.toContain('per-render-site hook context')
|
||||
expect(plain).not.toContain('shapes those fields reference')
|
||||
})
|
||||
|
||||
it('describeClient expands one seat into its full register contract', () => {
|
||||
const lines = describeClient([SEAT, LIST_SEAT], ['one rule'], 'demo.list').join('\n')
|
||||
expect(lines).toContain('exists: an entry in \'demo.parent\'')
|
||||
expect(lines).toContain('id (required, string) — Your cell key.')
|
||||
expect(lines).toContain('owner props: none')
|
||||
expect(lines).toContain('useSessions: Hook')
|
||||
expect(lines).toContain('minimal browser half:')
|
||||
expect(lines).toContain('ctx.slots.register(')
|
||||
// A narrowed report is one seat only, and carries no cross-cutting rules.
|
||||
expect(lines).not.toContain('demo.seat')
|
||||
expect(lines).not.toContain('one rule')
|
||||
})
|
||||
|
||||
it('describeDynamic tells the model whether a failed browser half is still on the page', () => {
|
||||
const row = (abdicated: boolean): unknown => ({
|
||||
id: 'dyn-1',
|
||||
name: 'panel',
|
||||
purpose: 'ui',
|
||||
hasHostHalf: false,
|
||||
hasClientHalf: true,
|
||||
run: { rev: 1, handlers: [] },
|
||||
renderFailure: { slot: 'settings.section', message: 'useX is not a function', abdicated },
|
||||
})
|
||||
const ctxFor = (abdicated: boolean): Context => ({
|
||||
dynamicCordisRunner: { snapshot: () => [row(abdicated)] },
|
||||
reflect: { store: {} },
|
||||
get: () => undefined,
|
||||
} as unknown as Context)
|
||||
|
||||
const gone = describeDynamic(ctxFor(true), {} as unknown as Agent).join('\n')
|
||||
expect(gone).toContain('BROWSER HALF FAILED TO RENDER at slot settings.section: useX is not a function')
|
||||
// The two states differ in the one fact the author needs: is my UI there?
|
||||
expect(gone).toContain('that seat was handed back to the shipped UI')
|
||||
const kept = describeDynamic(ctxFor(false), {} as unknown as Agent).join('\n')
|
||||
expect(kept).toContain('that seat is still yours, so what the page shows may be incomplete')
|
||||
})
|
||||
|
||||
it('describeClient names the shipped neighbours when an occupied list seat is expanded', () => {
|
||||
const lines = describeClient([LIST_SEAT_OCCUPIED], [], 'demo.list.busy').join('\n')
|
||||
expect(lines).toContain("additive (beside: client-demo DemoRow id 'shipped')")
|
||||
})
|
||||
|
||||
it('describeDynamic renders a browser-only row, a half-loaded host half, and a fiberless run', () => {
|
||||
// Rows a host-only harness cannot produce: the runner's own shapes are the
|
||||
// contract this renderer reads, so they are supplied directly.
|
||||
const pending = { state: FiberState.PENDING, name: 'half-loaded', inject: {} } as unknown as Fiber
|
||||
const rows = [
|
||||
{ id: 'dyn-1', name: 'browser only', purpose: 'ui', hasHostHalf: false, hasClientHalf: true },
|
||||
{ id: 'dyn-2', name: 'no fiber', purpose: 'client half only', hasHostHalf: false, hasClientHalf: true, run: { rev: 1, handlers: [] } },
|
||||
{ id: 'dyn-3', name: 'waiting', purpose: 'both halves', hasHostHalf: true, hasClientHalf: true, run: { rev: 2, fiber: pending, handlers: ['ping'] } },
|
||||
]
|
||||
const ctx = {
|
||||
dynamicCordisRunner: { snapshot: () => rows },
|
||||
reflect: { store: {} },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
// No agent means no definition space to report, not an empty registry.
|
||||
expect(describeDynamic(ctx)).toEqual([
|
||||
'No dynamic packages are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.',
|
||||
])
|
||||
const lines = describeDynamic(ctx, {} as unknown as Agent)
|
||||
expect(lines[0]).toBe('- dyn-1: browser only [defined, not running] (browser) — ui')
|
||||
expect(lines[1]).toBe('- dyn-2: no fiber [running, rev 1] (browser) — client half only; provides: none; waiting for: none')
|
||||
expect(lines[2]).toBe('- dyn-3: waiting [pending, rev 2] (host+browser) — both halves; provides: none; waiting for: none; host methods: ping')
|
||||
})
|
||||
|
||||
it('describeClient refuses an unknown slot key instead of answering emptily', () => {
|
||||
expect(() => describeClient([SEAT], [], 'nope.seat')).toThrow('no catalogued client slot named "nope.seat"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,22 +5,24 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import * as ToolCordis from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { call, REVERSE_TOOL_CODE, setup, text } 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.
|
||||
* Full-loop integration: a scripted mock model defines and runs a package 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
|
||||
* undefines it again. Only the model is mocked; the sandbox, the fiber tree, and
|
||||
* the session log are real — including the presentation metadata the card needs.
|
||||
*/
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(CordisHostRunner)
|
||||
await ctx.plugin(ToolCordis)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
@@ -38,11 +40,12 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
it('defines, runs, calls, and undefines a self-made tool — 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' }),
|
||||
toolCallResponse('call-1', 'cordis_define', { name: 'reverser', purpose: 'reverses text', code: REVERSE_TOOL_CODE }, 'Extending myself.'),
|
||||
toolCallResponse('call-2', 'cordis_run', { id: 'dyn-1' }),
|
||||
toolCallResponse('call-3', 'reverse_text', { text: 'harness' }),
|
||||
toolCallResponse('call-4', 'cordis_undefine', { id: 'dyn-1' }),
|
||||
textResponse('Done.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -53,36 +56,40 @@ describe('cordis tools through the agent loop', () => {
|
||||
|
||||
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'])
|
||||
expect(calls).toEqual(['cordis_define', 'cordis_run', 'reverse_text', 'cordis_undefine'])
|
||||
|
||||
const results = log.filter(event => event.type === 'tool/result')
|
||||
expect(results.map(event => event.data.message.content[0].isError)).toEqual([false, false, false])
|
||||
const reversed = results[1]!.data.message.content[0].content
|
||||
expect(results.map(event => event.data.message.content[0].isError)).toEqual([false, false, false, false])
|
||||
// The define result's durable metadata carries the minted id — this is what
|
||||
// a card reads to address run/stop, and replay reproduces it verbatim.
|
||||
expect(results[0]!.data.meta).toEqual({ id: 'dyn-1' })
|
||||
const reversed = results[2]!.data.message.content[0].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.
|
||||
// After the undefine the self-made tool is gone from the registry.
|
||||
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime', async () => {
|
||||
it('keeps a running package across turns, undefines it, and does not restore it in a new runtime', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }),
|
||||
toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }),
|
||||
toolCallResponse('define-1', 'cordis_define', { name: 'marker', purpose: 'marks the turn', code: 'return { name: \'turn-marker\', apply() {} }' }),
|
||||
toolCallResponse('run-1', 'cordis_run', { id: 'dyn-1' }),
|
||||
toolCallResponse('inspect-1', 'cordis_runtime_inspect', { what: 'temporary' }),
|
||||
textResponse('Turn one complete.'),
|
||||
toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }),
|
||||
toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }),
|
||||
toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }),
|
||||
toolCallResponse('inspect-2', 'cordis_runtime_inspect', { what: 'temporary' }),
|
||||
toolCallResponse('undefine-1', 'cordis_undefine', { id: 'dyn-1' }),
|
||||
toolCallResponse('inspect-3', 'cordis_runtime_inspect', { what: 'temporary' }),
|
||||
textResponse('Turn two complete.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Mount the marker and inspect it.' }], source: { kind: 'user' } }))
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Define and run the marker, then inspect it.' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, unmount it, then inspect again.' }], source: { kind: 'user' } }))
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, undefine it, then inspect again.' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const resultText = new Map(
|
||||
@@ -90,12 +97,14 @@ describe('cordis tools through the agent loop', () => {
|
||||
.filter(event => event.type === 'tool/result')
|
||||
.map(event => [event.data.message.source.callId, event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text).join('')]),
|
||||
)
|
||||
expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
|
||||
expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
|
||||
expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
|
||||
expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.')
|
||||
expect(resultText.get(CallId('inspect-1'))).toContain('- dyn-1: marker [running, rev 1] (host) — marks the turn')
|
||||
expect(resultText.get(CallId('inspect-2'))).toContain('- dyn-1: marker [running, rev 1] (host) — marks the turn')
|
||||
expect(resultText.get(CallId('undefine-1'))).toBe('Dynamic package dyn-1 is stopped and undefined; its id is now invalid.')
|
||||
expect(resultText.get(CallId('inspect-3'))).toContain('No dynamic packages are defined in this session.')
|
||||
|
||||
// A fresh runtime restores nothing: definitions never left this process.
|
||||
const restarted = await setup()
|
||||
expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
expect(text(await call(restarted, 'cordis_runtime_inspect', { what: 'temporary' })))
|
||||
.toContain('No dynamic packages are defined in this session.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,897 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { sandboxDefineTool } from '../src/guard.ts'
|
||||
import { syntaxErrorContext } from '../src/sandbox.ts'
|
||||
import { call, CONTENT_OUTPUT_CODE, 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.each([
|
||||
[42, 'options must be an object'],
|
||||
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise<null> => null }, 'output.render must be a function'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'],
|
||||
[{
|
||||
parameters: {},
|
||||
output: { schema: { type: 'json' }, render: () => [], presentationMeta: true },
|
||||
execute: async (): Promise<null> => null,
|
||||
}, 'output.presentationMeta must be a function'],
|
||||
])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => {
|
||||
expect(() => sandboxDefineTool(definition)).toThrow(message)
|
||||
})
|
||||
|
||||
it('bounds the preview of an invalid dynamic renderer return', () => {
|
||||
const definition = sandboxDefineTool({
|
||||
name: 'invalid-renderer',
|
||||
description: 'invalid renderer',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => ['x'.repeat(500)],
|
||||
},
|
||||
execute: async () => 'ok',
|
||||
})
|
||||
expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/)
|
||||
})
|
||||
|
||||
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)
|
||||
if (result.isError) throw new Error('expected cordis_mount success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
pluginName: 'change-logger',
|
||||
state: 'active',
|
||||
provides: [],
|
||||
waitingFor: [],
|
||||
})
|
||||
expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until unmounted or DSH restarts).')
|
||||
|
||||
// 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)
|
||||
if (reversed.isError) throw new Error('expected dynamic tool success')
|
||||
expect(reversed.value).toBe('ssenrah')
|
||||
expect(text(reversed)).toBe('ssenrah')
|
||||
})
|
||||
|
||||
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
|
||||
// VM-realm objects fail the session prototype-identity check; normalize them into host JSON.
|
||||
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('projects presentation metadata from a dynamic canonical value', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'meta-return',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'meta_tool',
|
||||
description: 'attaches a private presentation payload',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) { return [{ type: 'text', text: value }] },
|
||||
presentationMeta() { return { kind: 'demo' } },
|
||||
},
|
||||
async execute() {
|
||||
return 'ok'
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'meta_tool', {})
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected dynamic tool success')
|
||||
expect(result.value).toBe('ok')
|
||||
expect(text(result)).toBe('ok')
|
||||
expect(result.meta).toEqual({ kind: 'demo' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'],
|
||||
['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'],
|
||||
['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'],
|
||||
['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'],
|
||||
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'],
|
||||
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
|
||||
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'bad-return',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'bad_return_tool',
|
||||
description: 'returns a wrong shape',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { ${returnStatement} },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'bad_return_tool', {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0]!.type).toBe('text')
|
||||
expect(text(result)).toContain(diagnostic)
|
||||
})
|
||||
|
||||
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'huge-return',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'huge_return_tool',
|
||||
description: 'returns a huge wrong shape',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return 'x'.repeat(500) },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'huge_return_tool', {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('returned invalid output')
|
||||
expect(text(result)).not.toContain('x'.repeat(200))
|
||||
})
|
||||
|
||||
it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => {
|
||||
// These common JSON-Schema spellings each have one DSL meaning, so normalize rather than
|
||||
// consume another model turn with a rejection.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'json-schema-tool',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'json_schema_tool',
|
||||
description: 'written in the JSON-Schema dialect',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
title: 'Raw parameters',
|
||||
default: { text: 'default' },
|
||||
examples: [{ text: 'example' }],
|
||||
properties: {
|
||||
text: { type: 'string', description: 'the text' },
|
||||
count: { type: 'integer', default: 1 },
|
||||
mode: { type: 'string', enum: ['fast', 'slow'] },
|
||||
extra: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
// The registered schema is canonical JSON Schema derived from the DSL:
|
||||
// the required array survived, integer stayed integer, extra is optional.
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
|
||||
const parameters = schema.parameters as {
|
||||
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
|
||||
required?: string[]
|
||||
}
|
||||
expect(parameters.required).toEqual(['text'])
|
||||
expect(parameters).toMatchObject({
|
||||
title: 'Raw parameters',
|
||||
default: { text: 'default' },
|
||||
examples: [{ text: 'example' }],
|
||||
})
|
||||
expect(parameters.properties.count!.type).toBe('integer')
|
||||
expect(parameters.properties.count!.default).toBe(1)
|
||||
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
|
||||
// Arg validation enforces the normalized spec: text required, extra not.
|
||||
expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)
|
||||
expect(text(await call(ctx, 'json_schema_tool', { text: 'ok', count: 2 }))).toBe('ok:2')
|
||||
})
|
||||
|
||||
it('normalizes a nested object property carrying a JSON-Schema required array', async () => {
|
||||
// On an object PROPERTY, a JSON-Schema-style `required` array names the
|
||||
// required children — the nested unwrap converts it just like the top level.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'nested-json-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'nested_json_schema_tool',
|
||||
description: 'nested dialect',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
|
||||
},
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'nested_json_schema_tool')!
|
||||
const cfg = (schema.parameters as { properties: { cfg: { required?: string[] } } }).properties.cfg
|
||||
expect(cfg.required).toEqual(['label'])
|
||||
expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
|
||||
})
|
||||
|
||||
it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'unified-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'unified_schema_tool',
|
||||
description: 'all unified nodes',
|
||||
parameters: {
|
||||
any: {
|
||||
type: 'json',
|
||||
title: 'Any JSON',
|
||||
default: { nested: [1, 'x', null] },
|
||||
examples: [{ ok: true }],
|
||||
},
|
||||
choice: {
|
||||
oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }],
|
||||
required: true,
|
||||
},
|
||||
flags: { type: 'array' },
|
||||
closed: { type: 'object', additionalProperties: false },
|
||||
count: { type: 'number', enum: [1, 2], const: 1 },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: String(args.choice) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'unified_schema_tool')!
|
||||
expect(schema.parameters).toMatchObject({
|
||||
properties: {
|
||||
any: { title: 'Any JSON', default: { nested: [1, 'x', null] }, examples: [{ ok: true }] },
|
||||
choice: { oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }] },
|
||||
flags: { type: 'array' },
|
||||
closed: { type: 'object', additionalProperties: false },
|
||||
count: { type: 'number', enum: [1, 2], const: 1 },
|
||||
},
|
||||
required: ['choice'],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
|
||||
const ctx = await setup()
|
||||
const depth = 5_000
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'deep-unified-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
let choice = { type: 'string' }
|
||||
let example = 'leaf'
|
||||
for (let index = 0; index < ${depth}; index++) {
|
||||
choice = { oneOf: [choice, { type: 'null' }] }
|
||||
example = [example]
|
||||
}
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'deep_unified_schema_tool',
|
||||
description: 'deep unified nodes',
|
||||
parameters: {
|
||||
choice: { ...choice, required: true },
|
||||
any: { type: 'json', default: example },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
const parameters = ctx.tools.schemas().find(s => s.name === 'deep_unified_schema_tool')!.parameters as {
|
||||
properties: Record<string, Record<string, unknown>>
|
||||
}
|
||||
let choice = parameters.properties.choice!
|
||||
let choiceDepth = 0
|
||||
while (Array.isArray(choice.oneOf)) {
|
||||
choice = choice.oneOf[0] as Record<string, unknown>
|
||||
choiceDepth++
|
||||
}
|
||||
let example: unknown = parameters.properties.any!.default
|
||||
let exampleDepth = 0
|
||||
while (Array.isArray(example)) {
|
||||
example = example[0]
|
||||
exampleDepth++
|
||||
}
|
||||
expect({ choiceDepth, choice, exampleDepth, example }).toEqual({
|
||||
choiceDepth: depth,
|
||||
choice: { type: 'string' },
|
||||
exampleDepth: depth,
|
||||
example: 'leaf',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'raw-unified-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'raw_unified_schema_tool',
|
||||
description: 'raw unified nodes',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
any: { description: 'unconstrained' },
|
||||
cfg: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { label: { type: 'string' } },
|
||||
required: ['label'],
|
||||
},
|
||||
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(ctx.tools.schemas().find(s => s.name === 'raw_unified_schema_tool')!.parameters).toMatchObject({
|
||||
properties: {
|
||||
any: {},
|
||||
cfg: { additionalProperties: false, required: ['label'] },
|
||||
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['parameters: 42', 'must be a ParameterSchemaSpec object'],
|
||||
['parameters: Object.defineProperty({}, \'text\', { value: { type: \'string\' } })', 'parameters must contain only own enumerable string keys'],
|
||||
['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'],
|
||||
['parameters: { text: Object.defineProperty({ type: \'string\' }, \'minimum\', { value: 1 }) }', 'parameters.text must contain only own enumerable string keys'],
|
||||
['parameters: { text: { type: \'string\', [Symbol(\'hidden\')]: true } }', 'parameters.text must contain only own enumerable string keys'],
|
||||
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'],
|
||||
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'],
|
||||
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'],
|
||||
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'],
|
||||
['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'],
|
||||
['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'],
|
||||
['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'],
|
||||
['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'],
|
||||
['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: { type: \'object\', properties: {}, required: [42] }', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: (() => { const required = []; required.length = 1; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: (() => { const required = []; required.length = 1; required.extra = true; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: (() => { class Names extends Array { *[Symbol.iterator]() {} }; const required = new Names(); required[0] = \'text\'; required.length = 1; return { type: \'object\', properties: { text: { type: \'string\' } }, required } })()', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'],
|
||||
['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'],
|
||||
['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'],
|
||||
['parameters: { type: \'object\', properties: { text: { type: \'json\' } } }', 'parameters.text must declare a valid type'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', additionalProperties: \'no\' } } }', 'parameters.cfg.additionalProperties must be a boolean'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', properties: 42 } } }', 'parameters.cfg.properties must be an object of schemas'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'],
|
||||
['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'],
|
||||
['parameters: { value: { oneOf: new (class Branches extends Array {})({ type: \'string\' }, { type: \'null\' }) } }', 'parameters.value.oneOf must contain at least two schemas'],
|
||||
['parameters: { value: { oneOf: Object.assign([{ type: \'string\' }, { type: \'null\' }], { extra: true }) } }', 'parameters.value.oneOf must contain at least two schemas'],
|
||||
['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'],
|
||||
['parameters: { value: { type: \'string\', enum: new (class Values extends Array {})(\'a\', \'b\') } }', 'parameters.value.enum must be a non-empty array'],
|
||||
['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Object.assign([1], { extra: true }) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: (() => { const v = Array(1); v.extra = true; return v })() } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; Object.defineProperty(p, \'constructor\', { value: C }); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
|
||||
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; const r = Proxy.revocable(C, {}); Object.defineProperty(p, \'constructor\', { value: r.proxy }); r.revoke(); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
|
||||
['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
|
||||
])('rejects a malformed ParameterSchemaSpec (%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},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
`
|
||||
const parameters = {}
|
||||
const item = { type: 'array' }
|
||||
item.items = item
|
||||
parameters.item = item
|
||||
`,
|
||||
'parameters.item.items is circular',
|
||||
],
|
||||
[
|
||||
`
|
||||
const parameters = {}
|
||||
const item = { type: 'object', additionalProperties: true, properties: parameters }
|
||||
parameters.item = item
|
||||
`,
|
||||
'parameters.item.properties is circular',
|
||||
],
|
||||
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'circular-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
${declaration}
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'circular_schema_tool',
|
||||
description: 'circular',
|
||||
parameters,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(message)
|
||||
})
|
||||
|
||||
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'proto-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'proto_schema_tool',
|
||||
description: 'literal JSON keys',
|
||||
parameters: {
|
||||
['__proto__']: { type: 'string', required: true },
|
||||
value: { type: 'json', default: { ['__proto__']: { safe: true } } },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const parameters = ctx.tools.schemas().find(schema => schema.name === 'proto_schema_tool')!.parameters as {
|
||||
properties: Record<string, { default?: unknown }>
|
||||
required?: string[]
|
||||
}
|
||||
expect(Object.hasOwn(parameters.properties, '__proto__')).toBe(true)
|
||||
expect(parameters.required).toContain('__proto__')
|
||||
const defaultValue = parameters.properties.value!.default as Record<string, unknown>
|
||||
expect(Object.hasOwn(defaultValue, '__proto__')).toBe(true)
|
||||
expect(defaultValue.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
|
||||
it('accepts a nested object/array ParameterSchemaSpec (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', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
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: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
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) {
|
||||
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)
|
||||
if (result.isError) throw new Error('expected pending cordis_mount success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
pluginName: 'waiter',
|
||||
state: 'pending',
|
||||
provides: [],
|
||||
waitingFor: ['no-such-service'],
|
||||
})
|
||||
expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until unmounted or DSH restarts).')
|
||||
// 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: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
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: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
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: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
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/Buffer, 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 Buffer, 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.each([
|
||||
['require(\'fs\')', 'require is not available in the temporary Plugin sandbox', 'inject: [\'fs\']'],
|
||||
['setTimeout(() => {}, 5)', 'setTimeout is not available in the temporary Plugin sandbox', 'ctx.setTimeout'],
|
||||
['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'],
|
||||
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(trapMessage)
|
||||
expect(text(result)).toContain(redirect)
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'ticker',
|
||||
inject: ['timer'],
|
||||
apply(ctx) {
|
||||
ctx.setTimeout(() => console.log('tick'), 10)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('is running')
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
|
||||
})
|
||||
|
||||
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.on) } }
|
||||
`,
|
||||
})
|
||||
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', 'function')
|
||||
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: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
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.
|
||||
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' } } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
|
||||
import {
|
||||
presentDefineCall, presentPackageInspectCall, presentRunCall, presentRuntimeInspectCall,
|
||||
presentStopCall, presentUndefineCall,
|
||||
} 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' })
|
||||
expect(presentInspectCall({ what: 'events', name: 'tools/change' })).toEqual({
|
||||
describe('Cordis tool presenters', () => {
|
||||
it('renders runtime and Package inspection as read calls', () => {
|
||||
expect(presentRuntimeInspectCall({ what: 'api', name: 'tools' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: 'Inspect cordis runtime: events: tools/change',
|
||||
})
|
||||
})
|
||||
|
||||
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 temporary Cordis Plugin',
|
||||
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 temporary Cordis Plugin 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_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({
|
||||
title: 'Inspect cordis runtime: api: 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 temporary Cordis Plugin 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()
|
||||
expect(presentPackageInspectCall({ pluginId: 'clock-1', packageId: 'pkg-2' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: 'Inspect Cordis package clock-1/pkg-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders versioned define and lifecycle calls', () => {
|
||||
expect(presentDefineCall({
|
||||
plugin: { kind: 'existing', pluginId: 'clock-1' },
|
||||
name: 'Clock v2',
|
||||
purpose: 'show seconds',
|
||||
code: { host: 'HOST', client: 'CLIENT' },
|
||||
})).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: 'Define clock-1 package "Clock v2": show seconds',
|
||||
rawInput: { host: 'HOST', client: 'CLIENT' },
|
||||
})
|
||||
expect(presentRunCall({ pluginId: 'clock-1', packageId: 'pkg-2', mode: 'update' })).toEqual({
|
||||
card: 'generic', kind: 'execute', title: 'Update clock-1 with pkg-2',
|
||||
})
|
||||
expect(presentStopCall({ pluginId: 'clock-1' })).toEqual({
|
||||
card: 'generic', kind: 'execute', title: 'Stop dynamic plugin clock-1',
|
||||
})
|
||||
expect(presentUndefineCall({ pluginId: 'clock-1' })).toEqual({
|
||||
card: 'generic', kind: 'delete', title: 'Remove dynamic plugin clock-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('wires the split inspection presenters onto their tools', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('cordis_runtime_inspect')!.presentCall!({ what: 'tools' })).toMatchObject({
|
||||
kind: 'read', title: 'Inspect cordis runtime: tools',
|
||||
})
|
||||
expect(ctx.tools.get('cordis_package_inspect')!.presentCall!({
|
||||
pluginId: 'clock-1', packageId: 'pkg-1',
|
||||
})).toMatchObject({
|
||||
kind: 'read', title: 'Inspect Cordis package clock-1/pkg-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only
|
||||
* registration/eventing verbs, timer helpers, guarded tools, and injected services. Framework
|
||||
* members that expose an unguarded context are denied because they could bypass marker checks and
|
||||
* host-realm normalization; these tests pin that escape class.
|
||||
*/
|
||||
|
||||
/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
|
||||
async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
return text(result)
|
||||
}
|
||||
|
||||
describe('sandbox context façade — escape surface is closed', () => {
|
||||
it.each([
|
||||
['ctx.root', 'const c = ctx.root'],
|
||||
['ctx.parent', 'const c = ctx.parent'],
|
||||
['ctx.scope', 'const c = ctx.scope'],
|
||||
['ctx.fiber', 'const f = ctx.fiber'],
|
||||
['ctx.reflect', 'const r = ctx.reflect'],
|
||||
['ctx.registry', 'const r = ctx.registry'],
|
||||
['ctx.events', 'const e = ctx.events'],
|
||||
['ctx.extend()', 'ctx.extend({})'],
|
||||
['ctx.isolate()', 'ctx.isolate("x")'],
|
||||
['ctx.intercept()', 'ctx.intercept("x", {})'],
|
||||
['ctx.plugin()', 'ctx.plugin({ apply() {} })'],
|
||||
['ctx.set()', 'ctx.set("tools", 1)'],
|
||||
['ctx.mixin()', 'ctx.mixin("x", [])'],
|
||||
])('denies %s with a teaching error', async (_label, expr) => {
|
||||
const ctx = await setup()
|
||||
const message = await mountTouching(ctx, expr)
|
||||
expect(message).toContain('sandbox ctx does not expose')
|
||||
expect(message).toContain('withheld by design')
|
||||
})
|
||||
|
||||
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'root-bypass',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
ctx.root.tools.register({
|
||||
name: 'smuggled',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('sandbox ctx does not expose "root"')
|
||||
// The whole point: the bypass never reaches the registry.
|
||||
expect(ctx.tools.get('smuggled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects assignment to the façade rather than silently dropping it', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('sandbox ctx is read-only')
|
||||
})
|
||||
|
||||
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
|
||||
// A cordis Service instance carries `.ctx` (a real Context), so
|
||||
// `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return
|
||||
// guards reject that Context before the registration lands.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'svc-ctx-escape',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) {
|
||||
ctx.systemPrompt.ctx.root.tools.register({
|
||||
name: 'smuggled_via_service',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('returned a cordis Context, which the sandbox does not expose')
|
||||
expect(ctx.tools.get('smuggled_via_service')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
|
||||
// The return guard's Promise arm only fires for a HOST-realm Promise (a vm-realm one is not
|
||||
// `instanceof` the host `Promise`).
|
||||
const ctx = await setup()
|
||||
ctx.plugin({
|
||||
name: 'host-async-svc',
|
||||
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
|
||||
})
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'async-consumer',
|
||||
inject: ['hostAsync', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'do_fetch',
|
||||
description: 'awaits the host async service',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const value = await ctx.hostAsync.grab()
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'do_fetch', {})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('host-fetched')
|
||||
})
|
||||
|
||||
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'introspector',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
const sym = ctx[Symbol.iterator]
|
||||
console.log('probe', sym === undefined, 'tools' in ctx, 'on' in ctx, 'root' in ctx)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox context façade — inject gate on services', () => {
|
||||
it('denies an undeclared live service (property access), naming the inject fix', async () => {
|
||||
// `systemPrompt` is a live global service in the setup harness, but this
|
||||
// mount does not declare it — reaching it would let the mount depend on a
|
||||
// provider cordis does not know about, so it is refused.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('service "systemPrompt" is not injected')
|
||||
expect(text(result)).toContain('inject: [\'systemPrompt\', …]')
|
||||
})
|
||||
|
||||
it('denies an undeclared live service reached through ctx.get too', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('service "systemPrompt" is not injected')
|
||||
})
|
||||
|
||||
it('allows a service the mount DID declare in inject', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'declared',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) { console.log('has systemPrompt:', typeof ctx.systemPrompt) }
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('is running')
|
||||
})
|
||||
|
||||
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
|
||||
// Without declared inject, Cordis cannot park the consumer when its provider unmounts. The
|
||||
// façade refuses access up front instead of leaving a zombie tool.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
|
||||
})
|
||||
const undeclared = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'sloppy-consumer',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'greet_undeclared',
|
||||
description: 'uses greeter without declaring it',
|
||||
parameters: { n: { type: 'string', required: true } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
// The tool registers (its execute is lazy), but calling it hits the gate:
|
||||
// `ctx.greeter` is undeclared, so it fails with the teaching error rather
|
||||
// than silently working and later stranding.
|
||||
expect(undeclared.isError).toBe(false)
|
||||
const called = await call(ctx, 'greet_undeclared', { n: 'x' })
|
||||
expect(called.isError).toBe(true)
|
||||
expect(text(called)).toContain('service "greeter" is not injected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox tools façade — get is a read-only schema view', () => {
|
||||
it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
|
||||
// The finding: returning the raw ToolDefinition hands mount code the tool's execute
|
||||
// function, letting it bypass ToolRuntime.execute (and its pre/post hooks). get now
|
||||
// returns the same name/description/parameters view as schemas(), with no execute.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'reporter',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'report_view',
|
||||
description: 'reports the shape of a tool view',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const view = ctx.tools.get('cordis_mount')
|
||||
return [{ type: 'text', text: JSON.stringify({
|
||||
hasExecute: 'execute' in view,
|
||||
hasPresentCall: 'presentCall' in view,
|
||||
name: view.name,
|
||||
keys: Object.keys(view).sort(),
|
||||
}) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const reported = await call(ctx, 'report_view', {})
|
||||
expect(reported.isError).toBe(false)
|
||||
const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
|
||||
expect(shape.hasExecute).toBe(false)
|
||||
expect(shape.hasPresentCall).toBe(false)
|
||||
expect(shape.name).toBe('cordis_mount')
|
||||
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
|
||||
})
|
||||
|
||||
it('ctx.tools.get returns undefined for an unknown tool', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'unknown-probe',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'probe_unknown',
|
||||
description: 'reports whether an unknown tool resolves',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(text(await call(ctx, 'probe_unknown', {}))).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -20,32 +20,31 @@ describe('export shape', () => {
|
||||
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(unwrapped.inject).toEqual(['tools', 'dynamicCordisRunner'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(typeof unwrapped.Config).toBe('function')
|
||||
// The vm bound moved to the runner service with the sandbox it bounds, so
|
||||
// this toolset has no config of its own.
|
||||
expect('Config' in tool).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('registers the three cordis tools with the documented schemas', async () => {
|
||||
it('registers the six cordis tools with split inspection 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']))
|
||||
expect(names).not.toEqual(expect.arrayContaining(['cordis_try', 'cordis_stop']))
|
||||
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
|
||||
expect(names).toEqual(expect.arrayContaining([
|
||||
'cordis_runtime_inspect', 'cordis_package_inspect', 'cordis_define',
|
||||
'cordis_run', 'cordis_stop', 'cordis_undefine',
|
||||
]))
|
||||
// The one-shot mount pair retired with the two-step verbs.
|
||||
expect(names).not.toEqual(expect.arrayContaining(['cordis_mount']))
|
||||
expect(names).not.toEqual(expect.arrayContaining(['cordis_unmount']))
|
||||
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_runtime_inspect')!
|
||||
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
|
||||
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events'])
|
||||
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events', 'client'])
|
||||
expect(props.name?.type).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
const packageInspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_package_inspect')!
|
||||
const packageProps = (packageInspect.parameters as { properties: Record<string, { type?: string }> }).properties
|
||||
expect(packageProps).toMatchObject({ pluginId: { type: 'string' }, packageId: { type: 'string' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT, CONSUMER_CODE, LISTENER_CODE, PROVIDER_CODE, REVERSE_TOOL_CODE, call, defineAndRun, setup, setupWithBrowser, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The five model-facing tools driven through the real registry pipeline: define
|
||||
* records and mints, run starts and reports, stop and undefine unwind, and every
|
||||
* refusal reaches the model as a tool error carrying the runner's teaching text.
|
||||
* The runner's own semantics are covered by its package; here the subject is the
|
||||
* model-facing contract (arguments, canonical values, rendered text, metadata).
|
||||
*/
|
||||
|
||||
describe('cordis_define', () => {
|
||||
it('records a definition and carries the minted id in its value and its presentation metadata', async () => {
|
||||
const ctx = await setup()
|
||||
|
||||
const result = await call(ctx, 'cordis_define', {
|
||||
name: 'greeter',
|
||||
purpose: 'greets by name',
|
||||
code: PROVIDER_CODE,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_define success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
name: 'greeter',
|
||||
purpose: 'greets by name',
|
||||
hasHostHalf: true,
|
||||
hasClientHalf: false,
|
||||
})
|
||||
// The card addresses run/stop by this id, and only the durable metadata
|
||||
// carries it (the model never wrote it).
|
||||
expect(result.meta).toEqual({ id: 'dyn-1' })
|
||||
expect(text(result)).toBe(
|
||||
'Dynamic package dyn-1 ("greeter") is defined with a host half and is NOT running yet. '
|
||||
+ 'Run it with cordis_run id:"dyn-1", or let the user press start on its card.',
|
||||
)
|
||||
// Nothing ran: the provided service is absent until cordis_run.
|
||||
expect(ctx.get('greeter')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('names both halves in the rendered summary', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_define', {
|
||||
name: 'dual',
|
||||
purpose: 'both halves',
|
||||
code: PROVIDER_CODE,
|
||||
client: 'return () => {}',
|
||||
})
|
||||
expect(text(result)).toContain('is defined with a host + browser half')
|
||||
})
|
||||
|
||||
it('reports a parse failure as a tool error and records nothing', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_define', {
|
||||
name: 'broken',
|
||||
purpose: 'p',
|
||||
code: 'return { name: \'ts\' as const }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('plain JavaScript, not TypeScript')
|
||||
expect(text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' })))
|
||||
.toContain('No dynamic packages are defined in this session')
|
||||
})
|
||||
|
||||
it('refuses a definition with neither half', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_define', { name: 'empty', purpose: 'p' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('needs `code` (host half), `client` (browser half), or both')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis_run', () => {
|
||||
it('starts the host half and reports what it provides', async () => {
|
||||
const ctx = await setup()
|
||||
const { value } = await call(ctx, 'cordis_define', { name: 'greeter', purpose: 'p', code: PROVIDER_CODE }) as { value: { id: string } }
|
||||
|
||||
const result = await call(ctx, 'cordis_run', { id: value.id })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_run success')
|
||||
expect(result.value).toEqual({ id: 'dyn-1', rev: 1, provides: ['greeter'], waitingFor: [] })
|
||||
expect(text(result)).toContain('is running at rev 1: host half is running (provides: greeter)')
|
||||
expect(ctx.get('greeter')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps a package whose host half waits for a service, naming what it waits for', async () => {
|
||||
const ctx = await setup()
|
||||
const { value } = await call(ctx, 'cordis_define', { name: 'consumer', purpose: 'p', code: CONSUMER_CODE }) as { value: { id: string } }
|
||||
|
||||
const result = await call(ctx, 'cordis_run', { id: value.id })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('host half is pending (missing services: greeter)')
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets the agent give ITSELF a tool, callable on the next step', async () => {
|
||||
const ctx = await setup()
|
||||
await defineAndRun(ctx, REVERSE_TOOL_CODE)
|
||||
|
||||
expect(ctx.tools.get('reverse_text')).toBeDefined()
|
||||
expect(text(await call(ctx, 'reverse_text', { text: 'abc' }))).toBe('cba')
|
||||
})
|
||||
|
||||
it('runs a host-only package again without re-evaluating its host half', async () => {
|
||||
const ctx = await setup()
|
||||
const id = await defineAndRun(ctx, PROVIDER_CODE)
|
||||
|
||||
// Re-evaluating would collide on the provided service; binding a live host
|
||||
// half is what lets a second call succeed at all.
|
||||
const again = await call(ctx, 'cordis_run', { id })
|
||||
|
||||
expect(again.isError).toBe(false)
|
||||
if (again.isError) throw new Error('expected the re-run to succeed')
|
||||
expect(again.value).toMatchObject({ id, rev: 1 })
|
||||
})
|
||||
|
||||
it('reports a sandbox failure as a tool error, leaving nothing running', async () => {
|
||||
const ctx = await setup()
|
||||
const { value } = await call(ctx, 'cordis_define', {
|
||||
name: 'boom',
|
||||
purpose: 'p',
|
||||
code: 'throw new Error(\'host half exploded\')',
|
||||
}) as { value: { id: string } }
|
||||
|
||||
const result = await call(ctx, 'cordis_run', { id: value.id })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('host half exploded')
|
||||
expect(text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' }))).toContain('[defined, not running]')
|
||||
})
|
||||
|
||||
it('answers an unknown id with the memory-only explanation', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_run', { id: 'dyn-99' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('definitions live in memory only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis_stop', () => {
|
||||
it('unwinds the package\'s registrations before it returns, and keeps the definition runnable', async () => {
|
||||
const ctx = await setup()
|
||||
const id = await defineAndRun(ctx, REVERSE_TOOL_CODE)
|
||||
|
||||
const result = await call(ctx, 'cordis_stop', { id })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_stop success')
|
||||
expect(result.value).toEqual({ id })
|
||||
expect(text(result)).toContain('is stopped; its definition remains')
|
||||
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
|
||||
// Runnable again on a fresh revision, with no code re-sent.
|
||||
const again = await call(ctx, 'cordis_run', { id })
|
||||
expect(again.isError).toBe(false)
|
||||
expect(ctx.tools.get('reverse_text')).toBeDefined()
|
||||
})
|
||||
|
||||
it('refuses to stop a package that is not running', async () => {
|
||||
const ctx = await setup()
|
||||
const { value } = await call(ctx, 'cordis_define', { name: 'idle', purpose: 'p', code: PROVIDER_CODE }) as { value: { id: string } }
|
||||
|
||||
const result = await call(ctx, 'cordis_stop', { id: value.id })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('is not running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis_undefine', () => {
|
||||
it('stops a running package, forgets it, and invalidates its id', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const id = await defineAndRun(ctx, LISTENER_CODE)
|
||||
|
||||
const result = await call(ctx, 'cordis_undefine', { id })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_undefine success')
|
||||
expect(result.value).toEqual({ id, wasRunning: true })
|
||||
expect(text(result)).toContain('is stopped and undefined')
|
||||
const calls = log.mock.calls.length
|
||||
ctx.tools.register({
|
||||
name: 'post_undefine_trigger',
|
||||
description: 'test trigger',
|
||||
parameters: { type: 'object' as const, properties: {} },
|
||||
output: { schema: { type: 'null' as const }, render: () => [] },
|
||||
execute: async (): Promise<null> => null,
|
||||
})
|
||||
expect(log).toHaveBeenCalledTimes(calls)
|
||||
expect((await call(ctx, 'cordis_run', { id })).isError).toBe(true)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('forgets a defined-but-never-run package', async () => {
|
||||
const ctx = await setup()
|
||||
const { value } = await call(ctx, 'cordis_define', { name: 'idle', purpose: 'p', code: PROVIDER_CODE }) as { value: { id: string } }
|
||||
|
||||
const result = await call(ctx, 'cordis_undefine', { id: value.id })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_undefine success')
|
||||
expect(result.value).toEqual({ id: value.id, wasRunning: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session scope', () => {
|
||||
it('hides another session\'s package from every verb', async () => {
|
||||
const ctx = await setup()
|
||||
const id = await defineAndRun(ctx, PROVIDER_CODE)
|
||||
|
||||
// A call from a different agent addresses a different definition space.
|
||||
const other = await ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: 'call-other' as never,
|
||||
name: 'cordis_run',
|
||||
arguments: { id },
|
||||
agent: { ...AGENT, id: 'S-other' } as never,
|
||||
})
|
||||
|
||||
expect(other.isError).toBe(true)
|
||||
expect(text(other)).toContain('no dynamic package')
|
||||
})
|
||||
|
||||
it('refuses a dynamic-package call that arrives without an agent', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: 'call-agentless' as never,
|
||||
name: 'cordis_define',
|
||||
arguments: { name: 'x', purpose: 'p', code: PROVIDER_CODE },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('need a session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis_run with a browser half', () => {
|
||||
it('reports what each half provides and waits for once a page carried it out', async () => {
|
||||
const ctx = await setupWithBrowser(['someClientService'])
|
||||
const defined = await call(ctx, 'cordis_define', {
|
||||
name: 'both halves',
|
||||
purpose: 'host + browser',
|
||||
code: 'return { name: \'both-host\', apply(ctx) { ctx.provide(\'dynBoth\', {}) } }',
|
||||
client: 'return () => {}',
|
||||
})
|
||||
if (defined.isError) throw new Error('define failed')
|
||||
const result = await call(ctx, 'cordis_run', { id: (defined.value as { id: string }).id })
|
||||
if (result.isError) throw new Error(text(result))
|
||||
const value = result.value as { rev: number; provides: string[]; clientWaitingFor?: string[] }
|
||||
expect(value.rev).toBe(1)
|
||||
expect(value.provides).toEqual(['dynBoth'])
|
||||
// The answering page's own parked services ride back to the model.
|
||||
expect(value.clientWaitingFor).toEqual(['someClientService'])
|
||||
expect(text(result)).toContain('browser half is pending (missing services: someClientService)')
|
||||
})
|
||||
|
||||
it('reports a browser-only package as running even though no host fiber exists', async () => {
|
||||
const ctx = await setupWithBrowser()
|
||||
const defined = await call(ctx, 'cordis_define', {
|
||||
name: 'browser only',
|
||||
purpose: 'ui only',
|
||||
client: 'return () => {}',
|
||||
})
|
||||
if (defined.isError) throw new Error('define failed')
|
||||
const result = await call(ctx, 'cordis_run', { id: (defined.value as { id: string }).id })
|
||||
if (result.isError) throw new Error(text(result))
|
||||
const value = result.value as { rev: number; provides: string[]; waitingFor: string[] }
|
||||
// No host half means no fiber to read provides/waits from — not an error.
|
||||
expect(value.provides).toEqual([])
|
||||
expect(value.waitingFor).toEqual([])
|
||||
expect(text(result)).toContain('host half is running (provides: none)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis_undefine refusals', () => {
|
||||
it('fails loud for an id the registry never minted', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_undefine', { id: 'dyn-99' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('dyn-99')
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime 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)
|
||||
if (result.isError) throw new Error('expected cordis_unmount success')
|
||||
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
|
||||
expect(text(result)).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
|
||||
|
||||
// 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: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
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 temporary 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(ToolRuntime)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { AGENT, call, setup, text } from './helpers.ts'
|
||||
|
||||
const HOST = 'return { apply() {} }'
|
||||
|
||||
async function preStep(ctx: Awaited<ReturnType<typeof setup>>, messages: UserMessage[]) {
|
||||
return await agentEvents(ctx, AGENT).waterfall(
|
||||
'agent/pre-step',
|
||||
{ messages, turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages }),
|
||||
)
|
||||
}
|
||||
|
||||
describe('versioned Cordis tools', () => {
|
||||
it('defines Host and Client code under one code object and returns Host-minted identities', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_define', {
|
||||
plugin: { kind: 'new', idPrefix: 'clock' },
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
code: { host: HOST, client: 'return { apply() {} }' },
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.value).toMatchObject({
|
||||
pluginId: 'clock-1',
|
||||
packageId: 'pkg-1',
|
||||
hasHostHalf: true,
|
||||
hasClientHalf: true,
|
||||
})
|
||||
expect(result.meta).toEqual({ pluginId: 'clock-1', packageId: 'pkg-1' })
|
||||
expect(text(result)).toContain('clock-1/pkg-1')
|
||||
})
|
||||
|
||||
it('runs an exact Package and persists Plugin, Package, and Plugin Run metadata', async () => {
|
||||
const ctx = await setup()
|
||||
const defined = await call(ctx, 'cordis_define', {
|
||||
plugin: { kind: 'new', idPrefix: 'clock' },
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
code: { host: HOST },
|
||||
})
|
||||
const { pluginId, packageId } = defined.value as { pluginId: string; packageId: string }
|
||||
|
||||
const result = await call(ctx, 'cordis_run', { pluginId, packageId, mode: 'run' })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.value).toMatchObject({ pluginId, packageId, pluginRunId: 'run-1' })
|
||||
expect(result.meta).toEqual({ pluginId, packageId, pluginRunId: 'run-1' })
|
||||
})
|
||||
|
||||
it('injects a source-free Package reference and exposes source only through package inspection', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_define', {
|
||||
plugin: { kind: 'new', idPrefix: 'clock' },
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
code: { host: HOST },
|
||||
})
|
||||
const prompt = createUserMessage({
|
||||
content: [{ type: 'text', text: '请修改 @clock-1 的显示' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
|
||||
const decision = await preStep(ctx, [prompt])
|
||||
|
||||
expect(decision.kind).toBe('enter')
|
||||
if (decision.kind !== 'enter') return
|
||||
const injected = decision.messages.at(-1)?.content
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
.join('\n')
|
||||
expect(injected).toContain('"pluginId": "clock-1"')
|
||||
expect(injected).toContain('"packageId": "pkg-1"')
|
||||
expect(injected).not.toContain(HOST)
|
||||
expect(injected).toContain('cordis_package_inspect')
|
||||
expect(injected).toContain('plugin.kind="existing"')
|
||||
expect(injected).toContain('Do not create a new Plugin')
|
||||
|
||||
const inspected = await call(ctx, 'cordis_package_inspect', {
|
||||
pluginId: 'clock-1',
|
||||
packageId: 'pkg-1',
|
||||
})
|
||||
expect(inspected.isError).toBe(false)
|
||||
expect(inspected.value).toMatchObject({
|
||||
pluginId: 'clock-1',
|
||||
packageId: 'pkg-1',
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
code: { host: HOST },
|
||||
})
|
||||
expect(text(inspected)).toContain(HOST)
|
||||
})
|
||||
|
||||
it('exposes Package inspection through the runtime API catalog', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_runtime_inspect', {
|
||||
what: 'api',
|
||||
name: 'dynamicCordisRunner',
|
||||
}))
|
||||
|
||||
expect(report).toContain('inspectPackage(')
|
||||
expect(report).toContain('DynamicCordisPackageInspection')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user