Files
ycode/scripts/conversation-ipc-regression.ts
T

707 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
})