chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+706
View File
@@ -0,0 +1,706 @@
import assert from 'node:assert/strict'
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
import { createServer } from 'node:http'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } 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 { ChatMessage, ToolCallRecord } from '../src/shared/types'
interface TimelineEntry {
kind: 'branch' | 'prompt'
sessionId: string
entryId?: string
text?: string
}
interface TestState {
userData: string
home: string
handlers: Map<string, (...args: unknown[]) => unknown>
dialogPaths: string[]
nativeMessages: Map<string, Array<{ entryId: string; text: string }>>
failBranchIds: Set<string>
timeline: TimelineEntry[]
pendingPrompts: Array<{ sessionId: string; complete: () => void }>
holdSessions: Set<string>
activePrompts: number
maxActivePrompts: number
nextNativeId: number
speechStatus: number
speechRequests: Array<{ path: string; authorization: string | undefined; contentType: string | undefined; body: string }>
events: unknown[]
}
interface TestBundle {
registerIpc(getWindow: () => unknown): void
disposeOmpKernels(): Promise<void>
sessions: {
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
getMessages(sessionId: string): Promise<ChatMessage[]>
getPendingNativeBranch(sessionId: string): Promise<string | undefined>
setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise<void>
}
attachments: {
getAttachment(id: string): Promise<{ path?: string; name: string }>
}
}
const TEST_STATE_KEY = '__YCODE_CONVERSATION_IPC_REGRESSION__'
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function waitUntil(predicate: () => boolean | Promise<boolean>, message: string): Promise<void> {
for (let attempt = 0; attempt < 250; attempt++) {
if (await predicate()) return
await wait(10)
}
assert.fail(`Timed out: ${message}`)
}
async function waitForAssistant(
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>,
sessionId: string,
predicate: (message: ChatMessage) => boolean = (message) => message.status === 'done'
): Promise<ChatMessage[]> {
for (let attempt = 0; attempt < 250; attempt++) {
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
const last = messages.findLast((message) => message.role === 'assistant')
if (last && predicate(last)) return messages
await wait(10)
}
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
assert.fail(`Timed out waiting for assistant state: ${JSON.stringify(messages.at(-1))}`)
}
function assistant(messages: ChatMessage[]): ChatMessage {
const found = messages.findLast((message) => message.role === 'assistant')
assert.ok(found, 'expected an assistant message')
return found
}
function invokeFor(state: TestState) {
return (channel: string, ...args: unknown[]): Promise<unknown> => {
const handler = state.handlers.get(channel)
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
return Promise.resolve().then(() => handler({}, ...args))
}
}
function message(id: string, role: 'user' | 'assistant', content: string, extras: Partial<ChatMessage> = {}): ChatMessage {
return { id, role, content, createdAt: Date.now(), status: 'done', ...extras }
}
function seedNativeHistory(state: TestState, sessionId: string, entries: Array<[string, string]>): void {
state.nativeMessages.set(sessionId, entries.map(([entryId, text]) => ({ entryId, text })))
}
function nativePath(userData: string, sessionId: string): string {
return join(userData, 'omp-sessions', `session-${encodeURIComponent(sessionId)}.jsonl`)
}
async function buildBundle(tempRoot: string, state: TestState, name: string): Promise<TestBundle> {
const ipcEntry = join(process.cwd(), 'src', 'main', 'ipc.ts')
const sessionsEntry = join(process.cwd(), 'src', 'main', 'sessions.ts')
const attachmentsEntry = join(process.cwd(), 'src', 'main', 'attachments.ts')
const outfile = join(tempRoot, `${name}.mjs`)
const stateKey = JSON.stringify(TEST_STATE_KEY)
await build({
stdin: {
contents: [
`export { registerIpc, disposeOmpKernels } from ${JSON.stringify(ipcEntry)};`,
`export * as sessions from ${JSON.stringify(sessionsEntry)};`,
`export * as attachments from ${JSON.stringify(attachmentsEntry)};`
].join('\n'),
resolveDir: process.cwd(),
sourcefile: `${name}.ts`,
loader: 'ts'
},
outfile,
bundle: true,
external: ['node-pty'],
platform: 'node',
format: 'esm',
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
logLevel: 'silent',
plugins: [
testTerminalHostPlugin,
{
name: 'conversation-ipc-electron-and-omp-mocks',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
loader: 'js',
contents: `
const state = globalThis[${stateKey}];
export const app = {
getPath: (name) => name === 'userData' ? state.userData : name === 'home' ? state.home : 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 (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
};
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';
import { basename } from 'node:path';
const state = globalThis[${stateKey}];
export class OmpKernel extends EventEmitter {
constructor(_exe, _workspace, _profile, sessionPath) {
super();
this.sessionId = decodeURIComponent(basename(sessionPath).replace(/^session-/, '').replace(/\\.jsonl$/, ''));
if (!state.nativeMessages.has(this.sessionId)) state.nativeMessages.set(this.sessionId, []);
}
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
send(command) {
if (command.type === 'get_branch_messages') {
return Promise.resolve({ type: 'response', success: true, data: { messages: [...(state.nativeMessages.get(this.sessionId) || [])] } });
}
if (command.type === 'branch') {
const entryId = String(command.entryId || '');
state.timeline.push({ kind: 'branch', sessionId: this.sessionId, entryId });
if (state.failBranchIds.has(entryId)) return Promise.resolve({ type: 'response', success: false, error: 'mock branch failure' });
const entries = state.nativeMessages.get(this.sessionId) || [];
const index = entries.findIndex((entry) => entry.entryId === entryId);
if (index < 0) return Promise.resolve({ type: 'response', success: false, error: 'Invalid entry ID for branching' });
state.nativeMessages.set(this.sessionId, entries.slice(0, index));
}
return Promise.resolve({ type: 'response', success: true, data: {} });
}
branch(entryId) { return this.send({ type: 'branch', entryId }); }
prompt(text) {
state.timeline.push({ kind: 'prompt', sessionId: this.sessionId, text });
const entryId = 'native-prompt-' + (++state.nextNativeId);
const entries = state.nativeMessages.get(this.sessionId) || [];
entries.push({ entryId, text });
state.nativeMessages.set(this.sessionId, entries);
state.activePrompts++;
state.maxActivePrompts = Math.max(state.maxActivePrompts, state.activePrompts);
const complete = () => {
state.activePrompts--;
this.emit('event', { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'reply:' + text } });
this.emit('event', { type: 'agent_end', yielded: true });
};
if (state.holdSessions.has(this.sessionId)) state.pendingPrompts.push({ sessionId: this.sessionId, complete });
else setImmediate(complete);
return Promise.resolve({ type: 'response', success: true });
}
abort() {
setImmediate(() => this.emit('event', { type: 'prompt_result', status: 'aborted' }));
return Promise.resolve({ type: 'response', success: true });
}
respondToHostTool() { return Promise.resolve(); }
dispose() { return Promise.resolve(); }
}
export const detectOmpPath = () => 'mock-omp';
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
`
}))
pluginBuild.onResolve({ filter: /^\.\/agent\/loop$/ }, () => ({ path: 'agent-loop', namespace: 'mock-agent-loop' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-agent-loop' }, () => ({
loader: 'js',
contents: `export async function runTurn() { throw new Error('unexpected non-OMP fallback'); }`
}))
pluginBuild.onResolve({ filter: /^\.\/gateway$/ }, () => ({ path: 'gateway', namespace: 'mock-gateway' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-gateway' }, () => ({
loader: 'js', contents: 'export class Gateway { constructor() {} }'
}))
}
}
]
})
return await import(pathToFileURL(outfile).href) as TestBundle
}
function register(bundle: TestBundle, state: TestState): (channel: string, ...args: unknown[]) => Promise<unknown> {
state.handlers.clear()
bundle.registerIpc(() => ({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send: (_channel: string, event: unknown) => state.events.push(event) }
}))
return invokeFor(state)
}
async function testConcurrency(bundle: TestBundle, state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<void> {
const first = await invoke('session:create', 'concurrent one') as { id: string }
const second = await invoke('session:create', 'concurrent two') as { id: string }
state.holdSessions.add(first.id)
state.holdSessions.add(second.id)
const firstSend = await invoke('chat:send', { sessionId: first.id, text: 'concurrent-first' }) as {
modelId?: string
modelName?: string
}
await assert.rejects(invoke('chat:send', { sessionId: first.id, text: 'must-not-append' }), /正在运行/)
await invoke('chat:send', { sessionId: second.id, text: 'concurrent-second' })
await waitUntil(() => state.pendingPrompts.filter((item) => item.sessionId === first.id || item.sessionId === second.id).length === 2,
'both sessions should reach OMP concurrently')
const firstMessages = await invoke('session:messages', first.id) as ChatMessage[]
assert.equal(firstSend.modelId, assistant(firstMessages).modelId,
'chat:send returns the frozen model ID used by the persisted assistant message')
assert.equal(firstSend.modelName, assistant(firstMessages).modelName,
'chat:send returns the frozen model name used by the persisted assistant message')
assert.deepEqual(firstMessages.filter((item) => item.role === 'user').map((item) => item.content), ['concurrent-first'],
'a duplicate same-session send must not append a second user message')
assert.ok(state.maxActivePrompts >= 2, 'different sessions should be able to run in parallel')
const held = state.pendingPrompts.splice(0)
state.holdSessions.clear()
held.forEach((item) => item.complete())
const doneFirst = await waitForAssistant(invoke, first.id)
const doneSecond = await waitForAssistant(invoke, second.id)
assert.equal(assistant(doneFirst).status, 'done')
assert.equal(assistant(doneSecond).status, 'done')
const metadata = (await invoke('session:list')) as Array<{ id: string; title: string }>
assert.equal(metadata.find((item) => item.id === first.id)?.title, 'concurrent-first', 'first send should set its title')
}
async function seedActionHistory(
bundle: TestBundle,
state: TestState,
action: 'edit' | 'resend' | 'regenerate'
): Promise<{ sessionId: string; targetUserId: string; targetAssistantId: string }> {
const invoke = invokeFor(state)
const session = await invoke('session:create', `action-${action}`) as { id: string }
const targetUserId = `${action}-user-target`
const targetAssistantId = `${action}-assistant-target`
const history = [
message(`${action}-user-before`, 'user', 'first prompt', { nativeEntryId: `${action}-native-before` }),
message(`${action}-assistant-before`, 'assistant', 'first reply'),
message(targetUserId, 'user', 'old prompt', { nativeEntryId: `${action}-native-target` }),
message(targetAssistantId, 'assistant', 'old reply')
]
for (const item of history) await bundle.sessions.appendMessage(session.id, item)
seedNativeHistory(state, session.id, [
[`${action}-native-before`, 'first prompt'],
[`${action}-native-target`, 'old prompt']
])
return { sessionId: session.id, targetUserId, targetAssistantId }
}
async function testActionBranch(
bundle: TestBundle,
state: TestState,
action: 'edit' | 'resend' | 'regenerate'
): Promise<void> {
const invoke = invokeFor(state)
const seeded = await seedActionHistory(bundle, state, action)
const timelineStart = state.timeline.length
const fromMessageId = action === 'regenerate' ? seeded.targetAssistantId : seeded.targetUserId
await invoke('chat:send', {
sessionId: seeded.sessionId,
text: `${action}-replacement`,
action,
fromMessageId
})
const messages = await waitForAssistant(invoke, seeded.sessionId)
const activity = state.timeline.slice(timelineStart).filter((item) => item.sessionId === seeded.sessionId)
const branchIndex = activity.findIndex((item) => item.kind === 'branch' && item.entryId === `${action}-native-target`)
const promptIndex = activity.findIndex((item) => item.kind === 'prompt' && item.text?.split(/\r?\n/, 1)[0] === `${action}-replacement`)
assert.ok(branchIndex >= 0 && promptIndex > branchIndex, `${action} must branch by native entryId before prompting`)
assert.equal(activity[promptIndex].text, [`${action}-replacement`, messages[2].capabilityContext].filter(Boolean).join('\n\n'),
'native prompt must include the exact persisted turn context without changing the user text')
assert.match(activity[promptIndex].text ?? '', /<ycode-agent-mode mode="craft">/, 'native prompt declares the frozen craft mode')
assert.equal(messages.length, 4, `${action} should keep the old prefix and replace the selected suffix`)
assert.deepEqual(messages.slice(0, 2).map((item) => item.content), ['first prompt', 'first reply'])
assert.equal(messages[2].content, `${action}-replacement`)
assert.ok(messages[2].nativeEntryId?.startsWith('native-prompt-'), 'new user message should receive the returned native entryId')
assert.equal(await bundle.sessions.getPendingNativeBranch(seeded.sessionId), undefined,
'successful native entry lookup should clear the pending branch marker')
const archiveDirectory = join(state.userData, 'session-branches', encodeURIComponent(seeded.sessionId))
const archiveFiles = await readdir(archiveDirectory)
assert.equal(archiveFiles.length, 1, `${action} should archive the replaced branch`)
const archive = JSON.parse(await readFile(join(archiveDirectory, archiveFiles[0]), 'utf8')) as {
reason: string
session: { messages: ChatMessage[] }
}
assert.equal(archive.reason, action)
assert.equal(archive.session.messages.length, 4, 'archive should preserve the full pre-action conversation')
}
async function testActionBranchFailure(bundle: TestBundle, state: TestState): Promise<void> {
const invoke = invokeFor(state)
const seeded = await seedActionHistory(bundle, state, 'edit')
const before = await bundle.sessions.getMessages(seeded.sessionId)
state.failBranchIds.add('edit-native-target')
await assert.rejects(invoke('chat:send', {
sessionId: seeded.sessionId,
text: 'must-not-append-on-branch-failure',
action: 'edit',
fromMessageId: seeded.targetUserId
}), /branch failure/)
state.failBranchIds.delete('edit-native-target')
assert.deepEqual(await bundle.sessions.getMessages(seeded.sessionId), before,
'failed native branching must not archive or append a replacement user message')
assert.equal(await bundle.sessions.getPendingNativeBranch(seeded.sessionId), undefined,
'failed action branch should restore the prior marker')
}
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
const invoke = invokeFor(state)
for (const nativeAccepted of [false, true]) {
const session = await invoke('session:create', 'interrupted-resend') as { id: string }
await bundle.sessions.appendMessage(session.id, message('prefix-user', 'user', 'earlier', { nativeEntryId: 'prefix-native' }))
await bundle.sessions.appendMessage(session.id, message('prefix-assistant', 'assistant', 'earlier reply'))
await bundle.sessions.appendMessage(session.id, message('interrupted-user', 'user', 'interrupted prompt'))
await bundle.sessions.appendMessage(session.id, message('interrupted-assistant', 'assistant', 'partial', { status: 'cancelled', interrupted: true }))
seedNativeHistory(state, session.id, [['prefix-native', 'earlier'], ...(nativeAccepted ? [['unbound-native', 'interrupted prompt'] as [string, string]] : [])])
const start = state.timeline.length
await invoke('chat:send', { sessionId: session.id, text: 'interrupted prompt', action: 'resend', fromMessageId: 'interrupted-user' })
const result = await waitForAssistant(invoke, session.id)
assert.equal(result.length, 4)
assert.equal(result[2].content, 'interrupted prompt')
const branches = state.timeline.slice(start).filter((entry) => entry.kind === 'branch')
assert.equal(branches.length, nativeAccepted ? 1 : 0)
if (nativeAccepted) assert.equal(branches[0].entryId, 'unbound-native', 'recover the accepted native user without truncating its preceding context')
assert.ok(result[2].nativeEntryId?.startsWith('native-prompt-'))
}
}
function writeTool(id: string, path: string, oldContent: string | null, newContent: string): ToolCallRecord {
return {
id,
name: 'write_file',
args: { path },
argsRaw: JSON.stringify({ path }),
status: 'ok',
output: 'write complete',
result: { path, oldContent, newContent }
}
}
async function seedWriteHistory(
bundle: TestBundle,
state: TestState,
title: string,
path: string,
targetEntryId: string,
currentFile: string
): Promise<string> {
await writeFile(path, currentFile, 'utf8')
const session = await invokeFor(state)('session:create', title) as { id: string }
await bundle.sessions.appendMessage(session.id, message(`${title}-user`, 'user', 'write prompt', { nativeEntryId: targetEntryId }))
await bundle.sessions.appendMessage(session.id, message(`${title}-assistant`, 'assistant', 'write answer', {
tools: [writeTool(`${title}-write`, path, 'before', 'after')]
}))
seedNativeHistory(state, session.id, [[targetEntryId, 'write prompt']])
return session.id
}
async function testFeedbackAndRevert(bundle: TestBundle, state: TestState, workspace: string): Promise<{
markerSessionId: string
markerEntryId: string
}> {
const invoke = invokeFor(state)
const feedbackSession = await invoke('session:create', 'feedback') as { id: string }
const feedbackUser = message('feedback-user', 'user', 'feedback request')
const feedbackAssistant = message('feedback-assistant', 'assistant', 'feedback response')
await bundle.sessions.appendMessage(feedbackSession.id, feedbackUser)
await bundle.sessions.appendMessage(feedbackSession.id, feedbackAssistant)
await invoke('chat:feedback', feedbackSession.id, feedbackAssistant.id, 'like')
assert.equal((await bundle.sessions.getMessages(feedbackSession.id))[1].feedback, 'like')
await assert.rejects(invoke('chat:feedback', feedbackSession.id, feedbackUser.id, 'dislike'), /助手消息/)
await assert.rejects(invoke('chat:feedback', feedbackSession.id, 'missing', 'like'), /助手消息/)
await assert.rejects(invoke('chat:feedback', feedbackSession.id, feedbackAssistant.id, 'bad'), /反馈值无效/)
const conflictPath = join(workspace, 'conflict.txt')
const conflictId = await seedWriteHistory(bundle, state, 'conflict-revert', conflictPath, 'native-conflict-target', 'external edit')
const conflictBefore = await bundle.sessions.getMessages(conflictId)
await assert.rejects(invoke('chat:revert', conflictId, 'conflict-revert-user'), /外部修改/)
assert.equal(await readFile(conflictPath, 'utf8'), 'external edit', 'revert conflict must preserve the externally edited file')
assert.deepEqual(await bundle.sessions.getMessages(conflictId), conflictBefore, 'conflict should leave history intact')
assert.equal(await bundle.sessions.getPendingNativeBranch(conflictId), undefined)
const rejectedPath = join(workspace, 'branch-failure.txt')
const rejectedId = await seedWriteHistory(bundle, state, 'failed-revert', rejectedPath, 'native-failed-revert', 'after')
state.failBranchIds.add('native-failed-revert')
await assert.rejects(invoke('chat:revert', rejectedId, 'failed-revert-user'), /恢复失败/)
state.failBranchIds.delete('native-failed-revert')
assert.equal(await readFile(rejectedPath, 'utf8'), 'after', 'failed native branch must undo the file restore')
assert.equal((await bundle.sessions.getMessages(rejectedId)).length, 2, 'failed native branch must preserve history')
assert.equal(await bundle.sessions.getPendingNativeBranch(rejectedId), undefined)
const readOnlyId = await invoke('session:create', 'read-only-revert') as { id: string }
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only'))
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-assistant', 'assistant', 'read', {
tools: [{ id: 'read-tool', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }]
}))
await assert.rejects(invoke('chat:revert', readOnlyId.id, 'read-only-user'), /没有可恢复/)
const unknownPath = join(workspace, 'unknown-write.txt')
await writeFile(unknownPath, 'after', 'utf8')
const unknownId = await invoke('session:create', 'unknown-write-revert') as { id: string }
await bundle.sessions.appendMessage(unknownId.id, message('unknown-write-user', 'user', 'unknown write', {
nativeEntryId: 'native-unknown-write'
}))
await bundle.sessions.appendMessage(unknownId.id, message('unknown-write-assistant', 'assistant', 'unknown', {
tools: [{
id: 'unknown-write-tool', name: 'delete_file', args: {}, argsRaw: '{}', status: 'ok',
result: { path: unknownPath, oldContent: 'before', newContent: 'after' }
}]
}))
await assert.rejects(invoke('chat:revert', unknownId.id, 'unknown-write-user'), /无法完整回滚/)
assert.equal(await readFile(unknownPath, 'utf8'), 'after', 'unrecognized writes must not be treated as reversible')
const markerPath = join(workspace, 'persist-revert.txt')
const markerSessionId = await seedWriteHistory(bundle, state, 'pending-revert', markerPath, 'native-revert-target', 'after')
await invoke('chat:revert', markerSessionId, 'pending-revert-user')
assert.equal(await readFile(markerPath, 'utf8'), 'before', 'known complete host write should be restored')
assert.deepEqual(await bundle.sessions.getMessages(markerSessionId), [], 'successful revert should truncate the active history prefix')
const markerEntryId = await bundle.sessions.getPendingNativeBranch(markerSessionId)
assert.equal(markerEntryId, 'native-revert-target', 'successful revert should persist a replay marker')
const revertArchiveDirectory = join(state.userData, 'session-branches', encodeURIComponent(markerSessionId))
const revertArchiveFiles = await readdir(revertArchiveDirectory)
assert.equal(revertArchiveFiles.length, 1, 'revert should archive its abandoned branch')
const revertArchive = JSON.parse(await readFile(join(revertArchiveDirectory, revertArchiveFiles[0]), 'utf8')) as {
reason: string
session: { messages: ChatMessage[] }
}
assert.equal(revertArchive.reason, 'revert')
assert.equal(revertArchive.session.messages.length, 2, 'revert archive should preserve the messages removed from the active history')
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as {
sessions: Array<{ id: string; pendingNativeBranchId?: string }>
}
assert.equal(stored.sessions.find((item) => item.id === markerSessionId)?.pendingNativeBranchId, markerEntryId,
'pending native branch marker should be present on disk')
return { markerSessionId, markerEntryId: markerEntryId! }
}
async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: string): Promise<void> {
const invoke = invokeFor(state)
const pickedPath = join(tempRoot, 'picked.txt')
await writeFile(pickedPath, 'picked file content', 'utf8')
state.dialogPaths.push(pickedPath)
const picked = await invoke('attachments:pick') as Array<{ id: string; name: string; path?: string; size: number }>
assert.equal(picked.length, 1)
assert.equal(picked[0].name, 'picked.txt')
assert.equal(picked[0].size, Buffer.byteLength('picked file content'))
assert.ok(picked[0].path && (await readFile(picked[0].path, 'utf8')) === 'picked file content',
'picker should stage a real copy in user data')
const staged = await invoke('attachments:stage', {
name: '../safe-note.txt', mimeType: 'text/plain', dataBase64: Buffer.from('attachment body').toString('base64')
}) as { id: string; name: string; path?: string; size: number }
assert.equal(staged.name, 'safe-note.txt', 'staging should discard path components from the supplied filename')
assert.equal(staged.size, Buffer.byteLength('attachment body'))
assert.equal((await bundle.attachments.getAttachment(staged.id)).name, 'safe-note.txt')
assert.equal(await readFile(staged.path!, 'utf8'), 'attachment body')
await assert.rejects(invoke('attachments:stage', {
name: 'metadata.json', mimeType: 'text/plain', dataBase64: Buffer.from('no').toString('base64')
}), /文件名无效/)
const tamperedMetadata = join(state.userData, 'attachments', staged.id, 'metadata.json')
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, path: join(tempRoot, 'outside.txt') }), 'utf8')
const pathChecked = await bundle.attachments.getAttachment(staged.id)
assert.equal(pathChecked.path, staged.path, 'attachment metadata path must be recomputed inside the staged directory')
assert.notEqual(pathChecked.path, join(tempRoot, 'outside.txt'))
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, name: '../outside.txt', path: join(tempRoot, 'outside.txt') }), 'utf8')
await assert.rejects(bundle.attachments.getAttachment(staged.id), /元数据无效/)
const sendSession = await invoke('session:create', 'invalid-attachment') as { id: string }
await assert.rejects(invoke('chat:send', {
sessionId: sendSession.id, text: 'must not send', attachments: [{ ...staged, id: staged.id }]
}), /元数据无效/)
assert.deepEqual(await invoke('session:messages', sendSession.id), [], 'invalid attachment metadata must reject before appending')
const sendWithAttachment = await invoke('session:create', 'attachment-send') as { id: string }
await invoke('chat:send', { sessionId: sendWithAttachment.id, text: 'read attachment', attachments: [picked[0]] })
await waitForAssistant(invoke, sendWithAttachment.id)
const prompt = state.timeline.findLast((item) => item.kind === 'prompt' && item.sessionId === sendWithAttachment.id)
assert.ok(prompt?.text?.includes('附件 picked.txt:\npicked file content'), 'OMP prompt should receive staged file contents')
await assert.rejects(invoke('attachments:remove', picked[0].id), /已用于会话/)
assert.ok(await readFile(join(state.userData, 'attachments', picked[0].id, 'metadata.json'), 'utf8'),
'removing a committed attachment should leave the session source intact')
await invoke('attachments:remove', staged.id)
await assert.rejects(bundle.attachments.getAttachment(staged.id))
}
async function testSpeech(bundle: TestBundle, state: TestState): Promise<void> {
const server = createServer((request, response) => {
const chunks: Buffer[] = []
request.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
request.on('end', () => {
state.speechRequests.push({
path: request.url || '',
authorization: request.headers.authorization,
contentType: request.headers['content-type'],
body: Buffer.concat(chunks).toString('utf8')
})
response.writeHead(state.speechStatus, { 'content-type': 'application/json' })
response.end(state.speechStatus === 200 ? JSON.stringify({ text: 'recognized local audio' }) : JSON.stringify({ error: 'mock failure' }))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
assert.ok(address && typeof address === 'object')
const speechBaseUrl = `http://127.0.0.1:${address.port}`
const invoke = invokeFor(state)
await invoke('config:update', { speechBaseUrl, speechModel: 'local-test-model', apiKey: 'regression-only-key' })
const audioBase64 = Buffer.from('local fake wav bytes').toString('base64')
const result = await invoke('speech:transcribe', { audioBase64, mimeType: 'audio/wav' }) as { text: string }
assert.deepEqual(result, { text: 'recognized local audio' })
const request = state.speechRequests.at(-1)
assert.ok(request)
assert.equal(request.path, '/audio/transcriptions')
assert.equal(request.authorization, 'Bearer regression-only-key')
assert.ok(request.contentType?.includes('multipart/form-data; boundary='))
assert.ok(request.body.includes('name="model"') && request.body.includes('local-test-model'))
assert.ok(request.body.includes('filename="recording.wav"') && request.body.includes('local fake wav bytes'))
state.speechStatus = 503
await assert.rejects(invoke('speech:transcribe', { audioBase64, mimeType: 'audio/wav' }), /转写失败(503)/)
assert.equal(state.speechRequests.length, 2, 'both transcription requests should stay on the local server')
assert.ok(state.speechRequests.every((item) => item.path === '/audio/transcriptions'))
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
async function testPendingReplay(
tempRoot: string,
bundle: TestBundle,
state: TestState,
markerSessionId: string,
markerEntryId: string
): Promise<TestBundle> {
// A real OMP cold start loads the old file leaf; branch alone was not persisted.
seedNativeHistory(state, markerSessionId, [[markerEntryId, 'write prompt']])
const restarted = await buildBundle(tempRoot, state, 'conversation-ipc-restarted')
const invoke = register(restarted, state)
const before = await restarted.sessions.getMessages(markerSessionId)
assert.deepEqual(before, [], 'restarted session should load the archived active prefix')
state.failBranchIds.add(markerEntryId)
await assert.rejects(invoke('chat:send', { sessionId: markerSessionId, text: 'blocked until branch succeeds' }), /恢复已保存的原生会话分支/)
state.failBranchIds.delete(markerEntryId)
assert.deepEqual(await restarted.sessions.getMessages(markerSessionId), before,
'failed pending branch replay must not append a user message')
assert.equal(await restarted.sessions.getPendingNativeBranch(markerSessionId), markerEntryId,
'failed pending branch replay must retain its marker for a retry')
const start = state.timeline.length
await invoke('chat:send', { sessionId: markerSessionId, text: 'replayed branch prompt' })
const messages = await waitForAssistant(invoke, markerSessionId)
const activity = state.timeline.slice(start).filter((item) => item.sessionId === markerSessionId)
const branch = activity.findIndex((item) => item.kind === 'branch' && item.entryId === markerEntryId)
const prompt = activity.findIndex((item) => item.kind === 'prompt' && item.text?.split(/\r?\n/, 1)[0] === 'replayed branch prompt')
assert.ok(branch >= 0 && prompt > branch, 'fresh OMP kernel must replay the saved branch marker before prompt')
assert.equal(activity[prompt].text, ['replayed branch prompt', messages[0].capabilityContext].filter(Boolean).join('\n\n'),
'restart prompt retains the exact current turn context')
assert.match(activity[prompt].text ?? '', /<ycode-agent-mode mode="craft">/, 'restart prompt declares current craft mode')
assert.equal(messages[0].content, 'replayed branch prompt', 'persisted user content stays exact across restart')
assert.ok(messages[0].nativeEntryId?.startsWith('native-prompt-'), 'restarted send should map its returned native entryId')
assert.equal(await restarted.sessions.getPendingNativeBranch(markerSessionId), undefined,
'new native user entry should clear the marker after successful lookup')
return restarted
}
async function testColdRecovery(tempRoot: string, state: TestState, bundle: TestBundle): Promise<void> {
const invoke = invokeFor(state)
const session = await invoke('session:create', 'cold streaming recovery') as { id: string }
await bundle.sessions.appendMessage(session.id, message('cold-user', 'user', 'incomplete request', { status: 'done' }))
await bundle.sessions.appendMessage(session.id, message('cold-assistant', 'assistant', 'partial', {
requestId: 'cold-run',
status: 'streaming',
parts: [{ type: 'text', text: 'partial' }, { type: 'tool', toolId: 'cold-tool' }],
tools: [{ id: 'cold-tool', name: 'shell', args: {}, argsRaw: '{}', status: 'running', output: 'partial tool output' }]
}))
const cold = await buildBundle(tempRoot, state, 'conversation-ipc-cold-recovery')
const messages = await cold.sessions.getMessages(session.id)
const recovered = assistant(messages)
assert.equal(recovered.status, 'cancelled')
assert.equal(recovered.interrupted, true)
assert.equal(recovered.tools?.[0].status, 'cancelled')
assert.equal(recovered.tools?.[0].output, 'partial tool output', 'cold recovery should retain running tool output')
const disk = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as {
sessions: Array<{ id: string; messages: ChatMessage[] }>
}
const diskRecovered = disk.sessions.find((item) => item.id === session.id)?.messages.at(-1)
assert.equal(diskRecovered?.status, 'cancelled', 'cold recovery should persist its terminal status')
assert.equal(diskRecovered?.tools?.[0].status, 'cancelled')
assert.equal(diskRecovered?.tools?.[0].output, 'partial tool output')
}
async function main(): Promise<void> {
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-conversation-ipc-'))
const state: TestState = {
userData: join(tempRoot, 'userData'),
home: join(tempRoot, 'home'),
handlers: new Map(),
dialogPaths: [],
nativeMessages: new Map(),
failBranchIds: new Set(),
timeline: [],
pendingPrompts: [],
holdSessions: new Set(),
activePrompts: 0,
maxActivePrompts: 0,
nextNativeId: 0,
speechStatus: 200,
speechRequests: [],
events: []
}
await mkdir(state.userData, { recursive: true })
await mkdir(state.home, { recursive: true })
const globals = globalThis as typeof globalThis & Record<string, unknown>
const previousState = globals[TEST_STATE_KEY]
globals[TEST_STATE_KEY] = state
const bundles: TestBundle[] = []
try {
const bundle = await buildBundle(tempRoot, state, 'conversation-ipc-primary')
bundles.push(bundle)
const invoke = register(bundle, state)
const workspace = join(tempRoot, 'workspace')
await mkdir(workspace, { recursive: true })
state.dialogPaths.push(workspace)
assert.deepEqual(await invoke('workspace:choose'), { root: workspace, changed: true })
await testConcurrency(bundle, state, invoke)
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
await testActionBranchFailure(bundle, state)
await testInterruptedResend(bundle, state)
await testAttachments(bundle, state, tempRoot)
await testSpeech(bundle, state)
const { markerSessionId, markerEntryId } = await testFeedbackAndRevert(bundle, state, workspace)
const restarted = await testPendingReplay(tempRoot, bundle, state, markerSessionId, markerEntryId)
bundles.push(restarted)
await testColdRecovery(tempRoot, state, restarted)
console.log('conversation-ipc-regression: all checks passed')
} finally {
await Promise.all(bundles.map((bundle) => bundle.disposeOmpKernels().catch(() => undefined)))
globals[TEST_STATE_KEY] = previousState
await rm(tempRoot, { recursive: true, force: true })
}
}
void main().catch((error: unknown) => {
console.error('conversation-ipc-regression failed:', error)
process.exitCode = 1
})