Files
deepseek-harness/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts
T

156 lines
8.5 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import FileSystem from '@deepseek-ai/dsh-fs-local'
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import PersistentRuntime, { type Config } from '../src/persistent.ts'
async function setup(config: Config = {}) {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-persistent-ptc-'))
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(cwd, { recursive: true, force: true }) })
await ctx.plugin(FileSystem)
await ctx.plugin(Subprocess)
const fiber = await ctx.plugin(PersistentRuntime, config)
const runtime = ctx.ptcRuntime
const owner = {}
const run = (program: string, options: Partial<PtcRunRequest> = {}) =>
runtime.run(runtime.resolve({ program, bindings: [], cwd, stateOwner: owner, ...options }))
return { ctx, fiber, runtime, owner, run, cwd }
}
function bindings(value: PtcBindingFunction) {
return [{ global: 'tools', functions: { value }, errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }]
}
describe('persistent Node REPL', () => {
it('retains declarations, closures, object identity, and imports across evaluations', async () => {
const { run, cwd } = await setup()
expect(await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).toEqual({ logs: [] })
expect(await run('console.log("second call"); ({ count: increment(), same: box === alias, dir: path.basename(process.cwd()), env: Object.keys(process.env) })')).toEqual({ logs: ['second call'], value: { count: 2, same: true, dir: cwd.split(/[\\/]/).at(-1), env: [] } })
})
it('uses current host bindings through retained functions and saved method references', async () => {
const { run } = await setup()
const first = vi.fn(async () => 'old')
const second = vi.fn(async () => 'new')
expect((await run('const saved = tools.value; async function query() { return await saved({}) }; await query()', { bindings: bindings(first) })).value).toBe('old')
expect((await run('await query()', { bindings: bindings(second) })).value).toBe('new')
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(1)
expect((await run('let failure; try { await query() } catch (error) { console.log(error.toolName); failure = "unavailable" }; failure')).value).toBe('unavailable')
expect(second).toHaveBeenCalledTimes(1)
})
it('runs top-level await blocks with reusable local declarations and keeps outer state', async () => {
const { run } = await setup()
await run('let total = 0;')
const code = '{ const amount = await tools.value({}); total += amount; console.log(amount); }'
expect(await run(code, { bindings: bindings(async () => 2) })).toEqual({ logs: ['2'] })
expect(await run(code, { bindings: bindings(async () => 3) })).toEqual({ logs: ['3'] })
expect(await run('({ total, local: typeof amount })')).toEqual({ logs: [], value: { total: 5, local: 'undefined' } })
})
it('isolates owners and uses fresh state when no owner is supplied', async () => {
const { run, runtime, cwd } = await setup()
const temporary = (program: string) => runtime.run(runtime.resolve({ program, bindings: [], cwd }))
expect((await run('let privateValue = 7;')).error).toBeUndefined()
expect((await run('typeof privateValue', { stateOwner: {} })).value).toBe('undefined')
expect((await temporary('let temporary = 8;')).error).toBeUndefined()
expect((await temporary('typeof temporary')).value).toBe('undefined')
expect((await run('privateValue')).value).toBe(7)
})
it('releases an owner and reports missing variables until the caller recreates them', async () => {
const { run, runtime, owner } = await setup()
await run('let app = { ready: true };')
await runtime.release(owner)
const missing = await run('app.ready')
expect(missing.error?.kind).toBe('exception')
expect(missing.error?.message).toContain('app is not defined')
expect((await run('let app = { ready: true }; app.ready')).value).toBe(true)
await runtime.release(owner)
await runtime.release(owner)
})
it('keeps mutations before an exception and recovers after syntax and output errors', async () => {
const { run } = await setup()
await run('let count = 0;')
expect((await run('count++; throw new Error("expected failure")')).error?.message).toContain('expected failure')
expect((await run('count')).value).toBe(1)
expect((await run('const =')).error?.kind).toBe('exception')
expect((await run('() => count')).error?.kind).toBe('invalid-output')
expect((await run('count')).value).toBe(1)
})
it('cancels active execution, awaits process exit, and clears all variables', async () => {
const { run } = await setup()
const entered = Promise.withResolvers<true>()
const controller = new AbortController()
const pending = run('let app = {}; await tools.value({}); while (true) {}', {
signal: controller.signal,
bindings: bindings(async () => { entered.resolve(true); return null }),
})
await entered.promise
controller.abort()
expect((await pending).error?.kind).toBe('abort')
expect((await run('typeof app')).value).toBe('undefined')
})
it('resets a timed-out environment and does not reuse an exited process', async () => {
const { run } = await setup()
await run('let retained = 1;')
expect((await run('while (true) {}', { timeoutMs: 50 })).error?.kind).toBe('timeout')
expect((await run('typeof retained')).value).toBe('undefined')
expect((await run('process.exit(7)')).error?.kind).toBe('worker-exit')
expect((await run('40 + 2')).value).toBe(42)
})
it('rejects overlapping runs for one owner and permits other owners', async () => {
const { run } = await setup()
const entered = Promise.withResolvers<true>()
const proceed = Promise.withResolvers<null>()
const pending = run('await tools.value({})', { bindings: bindings(async () => { entered.resolve(true); return await proceed.promise }) })
try {
await entered.promise
await expect(run('1')).rejects.toThrow('concurrent runs')
expect((await run('2', { stateOwner: {} })).value).toBe(2)
} finally { proceed.resolve(null) }
expect((await pending).value).toBeNull()
})
it('disposes in-flight execution and removes the service on plugin unload', async () => {
const { run, fiber, ctx, runtime } = await setup()
const entered = Promise.withResolvers<true>()
const pending = run('await tools.value({}); await new Promise(() => {})', { bindings: bindings(async () => { entered.resolve(true); return null }) })
await entered.promise
await fiber.dispose()
expect((await pending).error?.kind).toBe('abort')
expect(ctx.get('ptcRuntime')).toBeUndefined()
expect(() => runtime.resolve({ program: '1', bindings: [] })).toThrow('after disposal')
await expect(runtime.run({ program: '1', bindings: [], cwd: process.cwd(), timeoutMs: null })).rejects.toThrow('after disposal')
})
it('rejects confinement requests and validates options before starting a process', async () => {
const { run, runtime } = await setup()
expect(runtime.sandboxMode).toBeUndefined()
expect(runtime.programFormat).toBe('repl')
expect(runtime.timeout).toEqual({ defaultMs: 120_000, maxMs: 600_000 })
expect(() => runtime.resolve({ program: '1', bindings: [], sandboxPolicy: { mode: 'read-only', workspaceRoot: process.cwd() } })).toThrow('sandbox policies')
expect(() => runtime.resolve({ program: '1', bindings: [], cwd: 'relative' })).toThrow('absolute')
expect(() => runtime.resolve({ program: '1', bindings: [], timeoutMs: 0 })).toThrow()
await expect(run('1', { bindings: [{ global: 'console', functions: {} }] })).rejects.toThrow('reserved')
expect((await run('1', { signal: AbortSignal.abort() })).error?.kind).toBe('abort')
expect((await run('42', { timeoutMs: null })).value).toBe(42)
})
it('bounds output and clears state after an overflow', async () => {
const { run } = await setup({ maxOutputBytes: 100 })
expect((await run('let retained = true; console.log("x".repeat(1000))')).error?.kind).toBe('output-limit')
expect((await run('typeof retained')).value).toBe('undefined')
})
})