294 lines
13 KiB
TypeScript
294 lines
13 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { build } from 'esbuild'
|
|
|
|
interface FakeChildState {
|
|
exePath: string
|
|
args: string[]
|
|
cwd: string
|
|
child: EventEmitter & {
|
|
pid: number
|
|
exitCode: number | null
|
|
signalCode: string | null
|
|
killed: boolean
|
|
stdin: { write: (payload: string) => boolean; end: () => void }
|
|
kill: () => boolean
|
|
}
|
|
}
|
|
|
|
interface KernelTestState {
|
|
children: FakeChildState[]
|
|
readers: EventEmitter[]
|
|
stdinWrites: string[]
|
|
autoExitOnEnd: boolean
|
|
stdinEnds: number
|
|
readerCloses: number
|
|
killCalls: number
|
|
}
|
|
|
|
let failures = 0
|
|
|
|
function check(label: string, condition: boolean, detail = ''): void {
|
|
if (condition) console.log(` ✓ ${label}`)
|
|
else {
|
|
failures++
|
|
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
|
}
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return true
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
}
|
|
return predicate()
|
|
}
|
|
|
|
async function buildKernelBundle(outfile: string): Promise<void> {
|
|
await build({
|
|
entryPoints: [join(process.cwd(), 'src', 'main', 'omp.ts')],
|
|
outfile,
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'esm',
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'omp-session-kernel-mocks',
|
|
setup(pluginBuild) {
|
|
pluginBuild.onResolve({ filter: /^node:child_process$/ }, () => ({
|
|
path: 'mock-child-process',
|
|
namespace: 'omp-session-mock'
|
|
}))
|
|
pluginBuild.onLoad({ filter: /^mock-child-process$/, namespace: 'omp-session-mock' }, () => ({
|
|
loader: 'js',
|
|
contents: `
|
|
import { EventEmitter } from 'node:events';
|
|
const state = globalThis.__YCODE_OMP_SESSION_TEST__;
|
|
class FakeChild extends EventEmitter {
|
|
constructor(exePath, args, cwd) {
|
|
super();
|
|
this.pid = 100 + state.children.length;
|
|
this.exitCode = null;
|
|
this.signalCode = null;
|
|
this.killed = false;
|
|
this.stdin = {
|
|
write: (payload) => {
|
|
state.stdinWrites.push(payload);
|
|
let command;
|
|
try { command = JSON.parse(payload); } catch { return true; }
|
|
if (typeof command.id === 'string' && command.type !== 'host_tool_result') {
|
|
const reader = state.readers[state.readers.length - 1];
|
|
const data = command.type === 'set_host_tools'
|
|
? { toolNames: (command.tools || []).map((tool) => tool.name) }
|
|
: command.type === 'get_branch_messages'
|
|
? { messages: [{ entryId: 'user-entry-1', text: 'saved prompt' }] }
|
|
: command.type === 'get_tree' ? { tree: [], leafId: null }
|
|
: command.type === 'get_messages' ? { messages: [] } : {};
|
|
setImmediate(() => reader?.emit('line', JSON.stringify({
|
|
id: command.id, type: 'response', command: command.type, success: true, data
|
|
})));
|
|
}
|
|
return true;
|
|
},
|
|
end: () => {
|
|
state.stdinEnds++;
|
|
if (state.autoExitOnEnd) setTimeout(() => this.finish(0, null), 5);
|
|
}
|
|
};
|
|
this.stdout = {};
|
|
this.stderr = new EventEmitter();
|
|
state.children.push({ exePath, args, cwd, child: this });
|
|
}
|
|
finish(code, signal) {
|
|
if (this.exitCode !== null || this.signalCode !== null) return;
|
|
this.exitCode = code;
|
|
this.signalCode = signal;
|
|
this.emit('exit', code, signal);
|
|
this.emit('close', code, signal);
|
|
}
|
|
kill() {
|
|
this.killed = true;
|
|
state.killCalls++;
|
|
this.finish(null, 'SIGTERM');
|
|
return true;
|
|
}
|
|
}
|
|
export function spawn(exePath, args, options) {
|
|
return new FakeChild(exePath, args, options.cwd);
|
|
}
|
|
`
|
|
}))
|
|
pluginBuild.onResolve({ filter: /^node:readline$/ }, () => ({
|
|
path: 'mock-readline',
|
|
namespace: 'omp-session-mock'
|
|
}))
|
|
pluginBuild.onLoad({ filter: /^mock-readline$/, namespace: 'omp-session-mock' }, () => ({
|
|
loader: 'js',
|
|
contents: `
|
|
import { EventEmitter } from 'node:events';
|
|
const state = globalThis.__YCODE_OMP_SESSION_TEST__;
|
|
export function createInterface() {
|
|
const reader = new EventEmitter();
|
|
reader.close = () => { state.readerCloses++; };
|
|
state.readers.push(reader);
|
|
return reader;
|
|
}
|
|
`
|
|
}))
|
|
}
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-session-regression-'))
|
|
const state: KernelTestState = {
|
|
children: [],
|
|
readers: [],
|
|
stdinWrites: [],
|
|
autoExitOnEnd: true,
|
|
stdinEnds: 0,
|
|
readerCloses: 0,
|
|
killCalls: 0
|
|
}
|
|
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_SESSION_TEST__?: KernelTestState }
|
|
const previous = globalWithMock.__YCODE_OMP_SESSION_TEST__
|
|
globalWithMock.__YCODE_OMP_SESSION_TEST__ = state
|
|
|
|
try {
|
|
const outfile = join(tempRoot, 'omp-kernel-regression.mjs')
|
|
await buildKernelBundle(outfile)
|
|
const { OmpKernel, parseFirstProfileModel } = await import(pathToFileURL(outfile).href)
|
|
|
|
const yaml = [
|
|
'providers:',
|
|
' local-probe:',
|
|
' api: openai-completions',
|
|
' models:',
|
|
' - id: probe-model',
|
|
' name: Probe model',
|
|
' second-provider:',
|
|
' models:',
|
|
' - id: second-model'
|
|
].join('\n')
|
|
check('profile model resolution returns provider/model, not profile/model',
|
|
parseFirstProfileModel(yaml) === 'local-probe/probe-model')
|
|
check('model resolution returns null when models.yml has no configured provider model',
|
|
parseFirstProfileModel('providers:\n empty-provider:\n models: []') === null)
|
|
|
|
const sessionPath = join(tempRoot, 'native', 'session-one.jsonl')
|
|
const selectedModel = 'local-probe/probe-model'
|
|
const persistent = new OmpKernel(process.execPath, tempRoot, 'ycode', sessionPath, selectedModel)
|
|
const persistentStart = persistent.start()
|
|
check('session parent directory is created before spawn', await waitFor(() => state.children.length === 1) &&
|
|
(await stat(dirname(sessionPath))).isDirectory())
|
|
check('persistent start reaches the ready frame', state.readers.length === 1)
|
|
state.readers[0]?.emit('line', JSON.stringify({ type: 'ready' }))
|
|
await persistentStart
|
|
const persistentArgs = state.children[0]?.args ?? []
|
|
check('persistent mode passes the configured provider/model selection',
|
|
persistentArgs.includes(`--model=${selectedModel}`))
|
|
check('built-in OMP tools are disabled by default so execution stays behind the host broker',
|
|
persistentArgs.includes('--no-tools'))
|
|
check('persistent mode passes the native resume path and session directory',
|
|
persistentArgs.includes(`--resume=${sessionPath}`) && persistentArgs.includes(`--session-dir=${dirname(sessionPath)}`))
|
|
check('persistent mode omits --no-session', !persistentArgs.includes('--no-session'))
|
|
|
|
const hostToolsAck = await persistent.setHostTools([{
|
|
name: 'read',
|
|
description: 'Read workspace text',
|
|
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }
|
|
}])
|
|
const branchMessages = await persistent.getBranchMessages()
|
|
const branchAck = await persistent.branch('user-entry-1')
|
|
await persistent.respondToHostTool('host-request-1', 'tool result')
|
|
const image = { type: 'image' as const, data: 'c21hbGw=', mimeType: 'image/png' }
|
|
const promptAck = await persistent.prompt('inspect this image', [image])
|
|
const writtenFrames = state.stdinWrites.map((payload) => JSON.parse(payload) as Record<string, unknown>)
|
|
const promptFrame = writtenFrames.find((frame) => frame.type === 'prompt')
|
|
const hostResultFrame = writtenFrames.find((frame) => frame.type === 'host_tool_result')
|
|
check('host tools, branch history, and images use their typed RPC helpers',
|
|
hostToolsAck.success === true && branchMessages.data.messages[0]?.entryId === 'user-entry-1' &&
|
|
branchAck.success === true && promptAck.success === true &&
|
|
Array.isArray(promptFrame?.images) &&
|
|
JSON.stringify((promptFrame.images as unknown[])[0]) === JSON.stringify(image) &&
|
|
hostResultFrame?.id === 'host-request-1')
|
|
|
|
let sawExit = false
|
|
persistent.once('exit', () => { sawExit = true })
|
|
await persistent.dispose()
|
|
check('dispose ends stdin and waits until the child exits', state.stdinEnds === 1 && sawExit)
|
|
check('kernel event listeners are removed after child exit', persistent.eventNames().length === 0)
|
|
|
|
const ephemeral = new OmpKernel(process.execPath, tempRoot, 'ycode', undefined, selectedModel)
|
|
const ephemeralStart = ephemeral.start()
|
|
check('ephemeral start creates a second child', await waitFor(() => state.children.length === 2))
|
|
state.readers[1]?.emit('line', JSON.stringify({ type: 'ready' }))
|
|
await ephemeralStart
|
|
const ephemeralArgs = state.children[1]?.args ?? []
|
|
check('three-argument constructors keep --no-session behavior',
|
|
ephemeralArgs.includes('--no-session') && !ephemeralArgs.some((arg) => arg.startsWith('--resume=')) &&
|
|
ephemeralArgs.includes('--no-tools'))
|
|
await ephemeral.dispose()
|
|
|
|
const missingModel = new OmpKernel(process.execPath, tempRoot, 'ycode', undefined, null)
|
|
let missingModelError: unknown
|
|
try {
|
|
await missingModel.start()
|
|
} catch (error) {
|
|
missingModelError = error
|
|
}
|
|
check('a profile without provider/model fails before spawn',
|
|
missingModelError instanceof Error && missingModelError.message.includes('provider/model') && state.children.length === 2)
|
|
|
|
state.autoExitOnEnd = false
|
|
const forceKilled = new OmpKernel(process.execPath, tempRoot, 'ycode', join(tempRoot, 'native', 'session-three.jsonl'), selectedModel)
|
|
const forceKillStart = forceKilled.start()
|
|
check('force-kill case starts a third child', await waitFor(() => state.children.length === 3))
|
|
state.readers[2]?.emit('line', JSON.stringify({ type: 'ready' }))
|
|
await forceKillStart
|
|
check('built-in OMP tools remain disabled for every kernel session',
|
|
state.children[2]?.args.includes('--no-tools') === true)
|
|
const killStartedAt = Date.now()
|
|
const keepAlive = setTimeout(() => undefined, 1800)
|
|
await forceKilled.dispose()
|
|
clearTimeout(keepAlive)
|
|
const killDelay = Date.now() - killStartedAt
|
|
check('stalled child is killed after the 1500ms grace period',
|
|
state.killCalls === 1 && state.children[2]?.child.killed === true && killDelay >= 1400 && killDelay < 2500,
|
|
`${killDelay}ms`)
|
|
|
|
const recoverable = new OmpKernel(process.execPath, tempRoot, 'ycode', sessionPath, selectedModel)
|
|
const firstStart = recoverable.start()
|
|
check('recoverable kernel creates a fresh sidecar', await waitFor(() => state.children.length === 4))
|
|
state.readers[3]?.emit('line', JSON.stringify({ type: 'ready' }))
|
|
await firstStart
|
|
state.children[3]?.child.finish(1, null)
|
|
const recoveredStart = recoverable.start()
|
|
check('kernel can restart after its sidecar exits', await waitFor(() => state.children.length === 5))
|
|
state.readers[4]?.emit('line', JSON.stringify({ type: 'ready' }))
|
|
await recoveredStart
|
|
const recoveredArgs = state.children[4]?.args ?? []
|
|
check('restarted sidecar reuses the same resume path and provider/model',
|
|
recoveredArgs.includes(`--resume=${sessionPath}`) && recoveredArgs.includes(`--model=${selectedModel}`))
|
|
await recoverable.dispose()
|
|
|
|
console.log(failures === 0 ? '\nOMP session regression checks passed.\n' : `\n${failures} OMP session regression check(s) failed.\n`)
|
|
process.exitCode = failures === 0 ? 0 : 1
|
|
} finally {
|
|
globalWithMock.__YCODE_OMP_SESSION_TEST__ = previous
|
|
await rm(tempRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error('OMP session regression checks aborted:', error)
|
|
process.exitCode = 1
|
|
})
|