Files
ycode/scripts/omp-regression.ts
T

445 lines
18 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import type { OmpFrame, OmpKernel } from '../src/main/omp'
import { runOmpTurn, type OmpTurnResult } from '../src/main/agent/omp-turn'
import type { ChatEvent } from '../src/shared/types'
type Outcome<T> =
| { kind: 'resolved'; value: T }
| { kind: 'rejected'; error: unknown }
| { kind: 'timeout' }
class FakeOmpKernel extends EventEmitter {
promptCalls = 0
abortCalls = 0
ack: OmpFrame = { type: 'response', success: true }
prompt(_message: string): Promise<OmpFrame> {
this.promptCalls++
return Promise.resolve(this.ack)
}
abort(): Promise<OmpFrame> {
this.abortCalls++
return Promise.resolve({ type: 'response', success: true })
}
setHostTools(tools: Array<{ name: string }>): Promise<OmpFrame<{ toolNames: string[] }>> {
return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } })
}
respondToHostTool(): Promise<void> {
return Promise.resolve()
}
}
let failures = 0
function check(label: string, condition: boolean, detail = ''): void {
if (condition) console.log(` ✓ ${label}`)
else {
failures++
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
}
}
function listenerCounts(kernel: FakeOmpKernel): Record<'event' | 'error' | 'exit', number> {
return {
event: kernel.listenerCount('event'),
error: kernel.listenerCount('error'),
exit: kernel.listenerCount('exit')
}
}
function checkListeners(label: string, kernel: FakeOmpKernel, baseline: ReturnType<typeof listenerCounts>): void {
const actual = listenerCounts(kernel)
check(
`${label}: event/error/exit listener counts return to baseline`,
JSON.stringify(actual) === JSON.stringify(baseline),
`baseline=${JSON.stringify(baseline)}, actual=${JSON.stringify(actual)}`
)
}
async function settleWithin<T>(promise: Promise<T>, timeoutMs = 250): Promise<Outcome<T>> {
let timer: NodeJS.Timeout | undefined
const timeout = new Promise<Outcome<T>>((resolve) => {
timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs)
})
const settled = promise.then<Outcome<T>, Outcome<T>>(
(value) => ({ kind: 'resolved', value }),
(error: unknown) => ({ kind: 'rejected', error })
)
const outcome = await Promise.race([settled, timeout])
if (timer) clearTimeout(timer)
return outcome
}
function turn(
kernel: FakeOmpKernel,
signal: AbortSignal,
events: ChatEvent[]
): Promise<OmpTurnResult> {
return runOmpTurn({
runId: 'run-regression',
kernel: kernel as unknown as OmpKernel,
userText: 'test prompt',
signal,
emit: (event) => events.push(event)
})
}
async function testTurnLifecycle(): Promise<void> {
console.log('\n[1] OMP turn lifecycle')
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const events: ChatEvent[] = []
const pending = turn(kernel, new AbortController().signal, events)
kernel.emit('event', {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'hello' }
})
kernel.emit('event', { type: 'agent_end', yielded: true })
const outcome = await settleWithin(pending)
check(
'agent_end resolves as done with accumulated text',
outcome.kind === 'resolved' && outcome.value.status === 'done' && outcome.value.content === 'hello'
)
check('normal completion emits done', events.some((event) => event.type === 'done'))
checkListeners('normal completion', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const controller = new AbortController()
const events: ChatEvent[] = []
const pending = turn(kernel, controller.signal, events)
controller.abort()
kernel.emit('event', { type: 'agent_end', yielded: true })
const outcome = await settleWithin(pending)
check(
'abort followed by agent_end resolves as cancelled',
outcome.kind === 'resolved' && outcome.value.status === 'cancelled'
)
check('abort requests kernel cancellation once', kernel.abortCalls === 1, `actual ${kernel.abortCalls}`)
check('agent_end after abort emits cancelled', events.some((event) => event.type === 'cancelled'))
checkListeners('agent_end cancellation', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const events: ChatEvent[] = []
const pending = turn(kernel, new AbortController().signal, events)
kernel.emit('event', { type: 'prompt_result', status: 'aborted' })
const outcome = await settleWithin(pending)
check(
'aborted prompt_result resolves as cancelled',
outcome.kind === 'resolved' && outcome.value.status === 'cancelled'
)
check('aborted prompt_result emits cancelled', events.some((event) => event.type === 'cancelled'))
checkListeners('prompt_result cancellation', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
kernel.ack = { type: 'response', success: false, error: { message: 'ack rejected' } }
const baseline = listenerCounts(kernel)
const events: ChatEvent[] = []
const pending = turn(kernel, new AbortController().signal, events)
const outcome = await settleWithin(pending)
check(
'a rejected prompt ack rejects with Error',
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'ack rejected',
outcome.kind === 'rejected' ? `rejection=${String(outcome.error)}` : `outcome=${outcome.kind}`
)
check('a rejected prompt ack emits error', events.some((event) => event.type === 'error'))
checkListeners('prompt ack error', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const events: ChatEvent[] = []
const pending = turn(kernel, new AbortController().signal, events)
kernel.emit('event', { type: 'prompt_result', status: 'error', error: { message: 'event rejected' } })
const outcome = await settleWithin(pending)
check(
'an error prompt_result rejects with Error',
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'event rejected'
)
check('an error prompt_result emits error', events.some((event) => event.type === 'error'))
checkListeners('prompt_result error', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const events: ChatEvent[] = []
const pending = turn(kernel, new AbortController().signal, events)
kernel.emit('error', new Error('kernel error'))
const outcome = await settleWithin(pending)
check(
'a kernel error rejects with Error',
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'kernel error'
)
checkListeners('kernel error', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const events: ChatEvent[] = []
const pending = turn(kernel, new AbortController().signal, events)
kernel.emit('exit', 1)
const outcome = await settleWithin(pending)
check(
'a kernel exit rejects with Error',
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message.includes('OMP 进程已退出')
)
checkListeners('kernel exit', kernel, baseline)
kernel.removeAllListeners()
}
{
const kernel = new FakeOmpKernel()
const baseline = listenerCounts(kernel)
const controller = new AbortController()
controller.abort()
const events: ChatEvent[] = []
const pending = turn(kernel, controller.signal, events)
const outcome = await settleWithin(pending, 75)
check(
'a pre-aborted signal rejects with AbortError',
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.name === 'AbortError',
`outcome=${outcome.kind}`
)
check(
'a pre-aborted signal sends neither prompt nor abort',
kernel.promptCalls === 0 && kernel.abortCalls === 0,
`prompt=${kernel.promptCalls}, abort=${kernel.abortCalls}`
)
if (outcome.kind === 'timeout') {
kernel.emit('event', { type: 'agent_end', yielded: true })
await pending.catch(() => undefined)
}
checkListeners('pre-aborted signal', kernel, baseline)
kernel.removeAllListeners()
}
}
interface IpcTestState {
userData: string
handlers: Map<string, (...args: unknown[]) => unknown>
kernel: EventEmitter | null
promptCalls: number
abortCalls: number
rejectNextPrompt: boolean
thinkingLevels: string[]
}
async function testIpcCancellationPersistence(): Promise<void> {
console.log('\n[2] IPC cancellation persistence')
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-regression-'))
const state: IpcTestState = {
userData: join(tempRoot, 'userData'),
handlers: new Map(),
kernel: null,
promptCalls: 0,
abortCalls: 0,
rejectNextPrompt: false,
thinkingLevels: []
}
await mkdir(state.userData, { recursive: true })
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_TEST__?: IpcTestState }
const previous = globalWithMock.__YCODE_OMP_TEST__
globalWithMock.__YCODE_OMP_TEST__ = state
let cleanupKernels: (() => Promise<void>) | undefined
try {
const entry = join(process.cwd(), 'src', 'main', 'ipc.ts')
const outfile = join(tempRoot, 'ipc-regression.mjs')
await build({
entryPoints: [entry],
outfile,
bundle: true,
external: ['node-pty'],
platform: 'node',
format: 'esm',
banner: { js: "import { createRequire as createIpcTestRequire } from 'node:module'; const require = createIpcTestRequire(import.meta.url);" },
logLevel: 'silent',
plugins: [
testTerminalHostPlugin,
{
name: 'ipc-regression-mocks',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
loader: 'js',
contents: `
const state = globalThis.__YCODE_OMP_TEST__;
export const app = {
getPath: (name) => name === 'userData' ? state.userData : state.userData,
getName: () => 'YCode',
getVersion: () => '0.1.0'
};
export const safeStorage = {
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value),
decryptString: (value) => value.toString()
};
export const powerSaveBlocker = { start: () => 1, stop: () => undefined, isStarted: () => true };
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const shell = { openPath: async () => '' };
export class BrowserWindow {}
export class WebContentsView {}
`
}))
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
loader: 'js',
contents: `
import { EventEmitter } from 'node:events';
export class OmpKernel extends EventEmitter {
constructor() { super(); globalThis.__YCODE_OMP_TEST__.kernel = this; }
setThinkingLevel(level) { globalThis.__YCODE_OMP_TEST__.thinkingLevels.push(level); return Promise.resolve(); }
prompt() {
const state = globalThis.__YCODE_OMP_TEST__;
state.promptCalls++;
if (state.rejectNextPrompt) {
state.rejectNextPrompt = false;
return Promise.resolve({ type: 'response', success: false, error: { message: 'mock OMP error' } });
}
return Promise.resolve({ type: 'response', success: true });
}
setHostTools(tools) {
return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } });
}
respondToHostTool() { return Promise.resolve(); }
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
abort() {
const state = globalThis.__YCODE_OMP_TEST__;
state.abortCalls++;
setImmediate(() => this.emit('event', { type: 'agent_end', yielded: true }));
return Promise.resolve({ type: 'response', success: true });
}
dispose() { return Promise.resolve(); }
}
export const detectOmpPath = () => 'mock-omp';
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
`
}))
}
}
]
})
const ipc = await import(pathToFileURL(outfile).href)
cleanupKernels = ipc.disposeOmpKernels
;(ipc.registerIpc as (getWindow: () => unknown) => void)(() => ({
webContents: { send: () => undefined }
}))
const invoke = (channel: string, ...args: unknown[]): Promise<unknown> => {
const handler = state.handlers.get(channel)
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
return Promise.resolve(handler({}, ...args))
}
const session = (await invoke('session:create', 'cancel persistence')) as { id: string }
const sent = (await invoke('chat:send', { sessionId: session.id, text: 'cancel this turn' })) as { runId: string }
// Native kernel acquisition is asynchronous; exercise cancellation after prompt dispatch.
for (let i = 0; i < 50 && state.promptCalls === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
const cancelled = (await invoke('chat:cancel', sent.runId)) as { cancelled: boolean }
check('chat:cancel accepts the active run', cancelled.cancelled === true)
let messages: Array<{ role: string; status?: string }> = []
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setTimeout(resolve, 10))
messages = (await invoke('session:messages', session.id)) as Array<{ role: string; status?: string }>
if (messages.at(-1)?.status !== 'streaming') break
}
check('the fake OMP kernel received one prompt and one abort', state.promptCalls === 1 && state.abortCalls === 1)
check('a non-reasoning model explicitly disables native thinking before prompt', state.thinkingLevels[0] === 'off')
check('cancelled assistant status is persisted through IPC', messages.at(-1)?.role === 'assistant' && messages.at(-1)?.status === 'cancelled', JSON.stringify(messages))
const sessionsFile = join(state.userData, 'sessions.json')
const readStoredMessages = async (sessionId: string): Promise<Array<{ role: string; status?: string; error?: string }>> => {
try {
const stored = JSON.parse(await readFile(sessionsFile, 'utf8')) as {
sessions: Array<{ id: string; messages: Array<{ role: string; status?: string; error?: string }> }>
}
return stored.sessions.find((item) => item.id === sessionId)?.messages ?? []
} catch {
return []
}
}
let diskMessages: Array<{ role: string; status?: string; error?: string }> = []
for (let i = 0; i < 50; i++) {
diskMessages = await readStoredMessages(session.id)
if (diskMessages.at(-1)?.status === 'cancelled') break
await new Promise((resolve) => setTimeout(resolve, 10))
}
check('cancelled status is present on disk', diskMessages.at(-1)?.status === 'cancelled', JSON.stringify(diskMessages))
state.rejectNextPrompt = true
const errorSession = (await invoke('session:create', 'error persistence')) as { id: string }
await invoke('chat:send', { sessionId: errorSession.id, text: 'fail this turn' })
let errorMessages: Array<{ role: string; status?: string; error?: string }> = []
for (let i = 0; i < 50; i++) {
errorMessages = (await invoke('session:messages', errorSession.id)) as Array<{ role: string; status?: string; error?: string }>
if (errorMessages.at(-1)?.status === 'error') break
await new Promise((resolve) => setTimeout(resolve, 10))
}
check(
'OMP error status and message are preserved in session memory',
errorMessages.at(-1)?.status === 'error' && errorMessages.at(-1)?.error === 'mock OMP error',
JSON.stringify(errorMessages)
)
let errorDiskMessages: Array<{ role: string; status?: string; error?: string }> = []
for (let i = 0; i < 50; i++) {
errorDiskMessages = await readStoredMessages(errorSession.id)
if (errorDiskMessages.at(-1)?.status === 'error') break
await new Promise((resolve) => setTimeout(resolve, 10))
}
check(
'OMP error status and message are preserved on disk',
errorDiskMessages.at(-1)?.status === 'error' && errorDiskMessages.at(-1)?.error === 'mock OMP error',
JSON.stringify(errorDiskMessages)
)
} finally {
await cleanupKernels?.()
globalWithMock.__YCODE_OMP_TEST__ = previous
await rm(tempRoot, { recursive: true, force: true })
}
}
async function main(): Promise<void> {
await testTurnLifecycle()
await testIpcCancellationPersistence()
console.log(failures === 0 ? '\nOMP regression checks passed.\n' : `\n${failures} OMP regression check(s) failed.\n`)
process.exitCode = failures === 0 ? 0 : 1
}
main().catch((error: unknown) => {
console.error('OMP regression checks aborted:', error)
process.exitCode = 1
})