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 testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise { const invoke = invokeFor(state) // 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。 const rolledPath = join(workspace, 'edit-rollback.txt') const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after') await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' }) const rolledMessages = await waitForAssistant(invoke, rolledSession) assert.equal(await readFile(rolledPath, 'utf8'), 'before', 'edit submit must roll the artifact back to its version before the message') assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement') assert.equal(rolledMessages[0].content, 'edited prompt') assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId') // 分支失败:撤销文件回退,保持会话不变。 const failedPath = join(workspace, 'edit-rollback-branch-failure.txt') const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after') const failedBefore = await bundle.sessions.getMessages(failedSession) state.failBranchIds.add('edit-rollback-failed-native') await assert.rejects(invoke('chat:send', { sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user' }), /branch failure/) state.failBranchIds.delete('edit-rollback-failed-native') assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback') assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore, 'failed edit must leave the persisted conversation untouched') // 无产物改动:直接重开对话,不因缺少文件记录而报错。 const plainSession = await invoke('session:create', 'edit-plain') as { id: string } await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' })) await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply')) seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']]) await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' }) const plainMessages = await waitForAssistant(invoke, plainSession.id) assert.equal(plainMessages.length, 2) assert.equal(plainMessages[0].content, 'edited plain') // 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。 const blockedPath = join(workspace, 'edit-blocked.txt') await writeFile(blockedPath, 'after', 'utf8') const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string } await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' })) await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', { tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }] })) seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']]) const blockedBefore = await bundle.sessions.getMessages(blockedSession.id) await assert.rejects(invoke('chat:send', { sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user' }), /无法完整回滚/) assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts') assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore, 'rejected edit must leave history untouched') } 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', { nativeEntryId: 'native-read-only-target' })) await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-assistant', 'assistant', 'read', { tools: [{ id: 'read-tool', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }] })) seedNativeHistory(state, readOnlyId.id, [['native-read-only-target', 'read only']]) // 原版 rewind 的 no-version 变体:没有产物版本时只回退对话内容,产物保持不变。 const readOnlyResult = await invoke('chat:revert', readOnlyId.id, 'read-only-user') as { messages: unknown[] } assert.deepEqual(readOnlyResult.messages, [], 'conversation-only rewind should truncate to before the message') assert.deepEqual(await bundle.sessions.getMessages(readOnlyId.id), [], 'conversation-only rewind should persist the truncated prefix') 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 testEditArtifactRollback(bundle, state, workspace) 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 })