445 lines
18 KiB
TypeScript
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
|
|
})
|