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 unknown> dialogPaths: string[] nativeMessages: Map> failBranchIds: Set timeline: TimelineEntry[] pendingPrompts: Array<{ sessionId: string; complete: () => void }> holdSessions: Set 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 sessions: { appendMessage(sessionId: string, message: ChatMessage): Promise getMessages(sessionId: string): Promise getPendingNativeBranch(sessionId: string): Promise setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise } attachments: { getAttachment(id: string): Promise<{ path?: string; name: string }> } } const TEST_STATE_KEY = '__YCODE_CONVERSATION_IPC_REGRESSION__' function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } async function waitUntil(predicate: () => boolean | Promise, message: string): Promise { 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, sessionId: string, predicate: (message: ChatMessage) => boolean = (message) => message.status === 'done' ): Promise { 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 => { 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 { 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 { 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 { 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): Promise { 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 { 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 ?? '', //, '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 { 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 { 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 { 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 { 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 { 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((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((resolve) => server.close(() => resolve())) } } async function testPendingReplay( tempRoot: string, bundle: TestBundle, state: TestState, markerSessionId: string, markerEntryId: string ): Promise { // 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 ?? '', //, '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 { 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 { 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 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 })