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 = | { 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 { this.promptCalls++ return Promise.resolve(this.ack) } abort(): Promise { this.abortCalls++ return Promise.resolve({ type: 'response', success: true }) } setHostTools(tools: Array<{ name: string }>): Promise> { return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } }) } respondToHostTool(): Promise { 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): 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(promise: Promise, timeoutMs = 250): Promise> { let timer: NodeJS.Timeout | undefined const timeout = new Promise>((resolve) => { timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs) }) const settled = promise.then, Outcome>( (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 { return runOmpTurn({ runId: 'run-regression', kernel: kernel as unknown as OmpKernel, userText: 'test prompt', signal, emit: (event) => events.push(event) }) } async function testTurnLifecycle(): Promise { 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 unknown> kernel: EventEmitter | null promptCalls: number abortCalls: number rejectNextPrompt: boolean thinkingLevels: string[] } async function testIpcCancellationPersistence(): Promise { 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) | 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 => { 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> => { 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 { 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 })