chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,436 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
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 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; }
|
||||
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() {}
|
||||
}
|
||||
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)
|
||||
;(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('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 {
|
||||
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
|
||||
})
|
||||
Reference in new issue
Block a user