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

932 lines
56 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
openPathError?: string
failStartTurn?: boolean
nativeFailurePath?: string
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')
// In-memory messages can reach the terminal status before the atomic write
// and terminal broadcast finish. Wait for the real turn barrier as well.
if (last && predicate(last) && !await invoke('chat:active-run', sessionId)) 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: /^\.\/sessions$/ }, () => ({ path: 'sessions', namespace: 'mock-session-start' }))
pluginBuild.onResolve({ filter: /sessions\.ts$/, namespace: 'mock-session-start' }, () => ({ path: sessionsEntry, namespace: 'file' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-session-start' }, () => ({
loader: 'js', contents: `
import * as real from ${JSON.stringify(sessionsEntry)};
export * from ${JSON.stringify(sessionsEntry)};
export async function startTurn(...args) {
const state = globalThis[${stateKey}];
if (state.failStartTurn) {
state.failStartTurn = false;
if (state.nativeFailurePath) await (await import('node:fs/promises')).writeFile(state.nativeFailurePath, 'changed-native-leaf');
throw new Error('mock turn persistence failure');
}
return real.startTurn(...args);
}
`
}))
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',
isPackaged: false,
isReady: () => true,
getAppPath: () => state.userData,
getLoginItemSettings: () => ({ openAtLogin: false }),
setLoginItemSettings: () => {}
};
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 () => state.openPathError || '', openExternal: async () => {} };
export const powerSaveBlocker = {
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
stop: () => true,
isStarted: () => (state.powerBlockers ?? 0) > 0
};
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: [] } }); }
setThinkingLevel() { return Promise.resolve(); }
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()
const mainWindow = {
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send: (channel: string, event: unknown) => { if (channel === 'chat:event') state.events.push(event) } }
}
bundle.registerIpc(() => mainWindow)
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 testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
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')
await invoke('chat:send', {
sessionId: blockedSession.id, text: 'edit without rolling files back', action: 'edit',
fromMessageId: 'edit-blocked-user', rollbackArtifacts: false
})
await waitForAssistant(invoke, blockedSession.id)
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'explicit skip must preserve current files even for non-reversible tools')
const keptPath = join(workspace, 'edit-keep-latest.txt')
const keptSession = await seedWriteHistory(bundle, state, 'edit-keep-latest', keptPath, 'keep-native', 'after')
await invoke('chat:send', { sessionId: keptSession, text: 'use latest files', action: 'edit',
fromMessageId: 'edit-keep-latest-user', rollbackArtifacts: false })
await waitForAssistant(invoke, keptSession)
assert.equal(await readFile(keptPath, 'utf8'), 'after', 'skipping artifact rollback retains the latest version')
const persistPath = join(workspace, 'edit-turn-persist-failed.txt')
const persistSession = await seedWriteHistory(bundle, state, 'edit-persist-failed', persistPath, 'edit-persist-native', 'after')
const persistHistory = structuredClone(await bundle.sessions.getMessages(persistSession))
await mkdir(join(state.userData, 'omp-sessions'), { recursive: true })
state.nativeFailurePath = nativePath(state.userData, persistSession)
await writeFile(state.nativeFailurePath, 'original-native-transcript', 'utf8')
state.failStartTurn = true
await assert.rejects(invoke('chat:send', { sessionId: persistSession, text: 'failed save', action: 'edit',
fromMessageId: 'edit-persist-failed-user' }), /turn persistence failure/)
assert.equal(await readFile(persistPath, 'utf8'), 'after', 'turn persistence failure must undo the already completed artifact rollback')
assert.equal(await readFile(state.nativeFailurePath, 'utf8'), 'original-native-transcript', 'failed edited turn must restore the native resume transcript too')
state.nativeFailurePath = undefined
assert.deepEqual(await bundle.sessions.getMessages(persistSession), persistHistory, 'failed edited turn persistence must preserve prior history')
}
async function testSenderRoutingAndPreflight(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
const invoke = invokeFor(state)
const session = await invoke('session:create', 'secondary window') as { id: string }
const events: Array<{ runId: string; type: string }> = []
let closed = false
const handler = state.handlers.get('chat:send')!
const sender = { isDestroyed: () => { if (closed) throw new Error('WebContents destroyed'); return false },
send: (channel: string, event: { runId: string; type: string }) => { if (channel === 'chat:event') events.push(event) } }
const sent = await handler({ sender }, { sessionId: session.id, text: 'secondary request' }) as { runId: string }
await waitForAssistant(invoke, session.id)
assert.ok(events.some((event) => event.type === 'text'), 'the requesting secondary renderer receives text deltas')
assert.ok(events.some((event) => event.type === 'done'), 'the requesting secondary renderer receives its terminal event')
assert.ok(state.events.some((event) => (event as { runId?: string }).runId === sent.runId), 'the main renderer also observes the shared turn')
const streamSequences = events.map((event) => (event as { sequence?: number }).sequence)
assert.ok(streamSequences.every((sequence, index) => typeof sequence === 'number' && (index === 0 || sequence > (streamSequences[index - 1] ?? -1))),
'shared events must carry a strictly increasing run sequence')
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /已有消息/,
'empty-session cleanup cannot remove a conversation populated by another renderer')
state.holdSessions.add(session.id)
await handler({ sender }, { sessionId: session.id, text: 'finish after window closes' })
await waitUntil(() => state.pendingPrompts.some((item) => item.sessionId === session.id), 'secondary held prompt')
const active = await invoke('chat:active-run', session.id) as { runId: string; messages: ChatMessage[]; sequence: number }
assert.ok(active?.runId && active.messages.at(-1)?.status === 'streaming', 'a reopened renderer can obtain the current run and exact live messages')
assert.equal(active.messages.filter((message) => message.role === 'user').length, 2, 'live snapshots include the complete history exactly once')
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /正在运行/,
'cleanup must reject a running session without cancelling its turn')
closed = true
const before = events.length
const held = state.pendingPrompts.find((item) => item.sessionId === session.id)!
state.pendingPrompts = state.pendingPrompts.filter((item) => item !== held)
state.holdSessions.delete(session.id)
held.complete()
await waitForAssistant(invoke, session.id)
assert.equal(events.length, before, 'closing the requester cannot interrupt persistence or send into a destroyed renderer')
const legacy = await invoke('session:create', 'legacy edit') as { id: string }
await bundle.sessions.appendMessage(legacy.id, message('legacy-user', 'user', 'imported input'))
await bundle.sessions.appendMessage(legacy.id, message('legacy-answer', 'assistant', 'imported answer'))
const denied = await invoke('chat:inspect-edit', legacy.id, 'legacy-user') as { editable: boolean; reason?: string }
assert.equal(denied.editable, false, 'legacy native branches must be checked before opening an editor')
assert.match(denied.reason ?? '', /原生分支/)
assert.deepEqual(await invoke('chat:inspect-edit', session.id, (await bundle.sessions.getMessages(session.id))[0].id), { editable: true })
const keepPath = join(workspace, 'rewind-keep.txt')
const keepSession = await seedWriteHistory(bundle, state, 'rewind-keep', keepPath, 'rewind-keep-native', 'after')
await invoke('chat:revert', keepSession, 'rewind-keep-user', { rollbackArtifacts: false })
assert.equal(await readFile(keepPath, 'utf8'), 'after', 'conversation-only rewind preserves artifact contents')
assert.deepEqual(await bundle.sessions.getMessages(keepSession), [], 'conversation-only rewind still persists the truncated history')
await invoke('session:delete', keepSession, { onlyIfEmpty: true })
assert.ok(!(await invoke('session:list') as Array<{ id: string }>).some((item) => item.id === keepSession), 'atomic cleanup removes a genuinely empty session')
state.openPathError = 'mock folder cannot be opened'
await assert.rejects(invoke('general:openFolder', 'workspace'), /mock folder cannot be opened/)
state.openPathError = undefined
}
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', { 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<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; sourcePath?: 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')
assert.equal(picked[0].sourcePath, pickedPath, 'display source path stays separate from the managed attachment copy')
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('resources:read-text', staged.path), /超出工作目录/)
const preview = await invoke('attachments:preview', staged.id) as { kind: string; content: string; path: string }
assert.equal(preview.kind, 'text')
assert.equal(preview.content, 'attachment body', 'registered attachment preview must work outside the current workspace')
assert.equal(await invoke('resources:read-text', preview.path), 'attachment body', 'the same registered attachment can be read by the real Markdown/code viewer')
await assert.rejects(invoke('resources:read-text', pickedPath), /超出工作目录/)
await assert.rejects(invoke('attachments:preview', '../outside'), /标识无效/)
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'))
assert.equal((await invoke('attachments:preview', staged.id) as { path: string }).path, staged.path,
'attachment preview must use the registered directory, not a metadata path override')
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 testInlineReferencePersistence(state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<string> {
const session = await invoke('session:create', 'inline reference persistence') as { id: string }
const quote = { id: 'inline-q', sourceId: 'original:table:table-0', kind: 'table', markdown: '| 项目 |\n| --- |\n| alpha |', preview: '| old raw markdown |' }
const layout = [{ type: 'text', text: 'before' }, { type: 'quote', id: quote.id }, { type: 'text', text: 'after' }]
await invoke('chat:send', { sessionId: session.id, text: 'beforeafter', quotes: [quote], userContent: [
...layout, { type: 'attachment', id: 'untrusted-view-only' }, { type: 'quote', id: quote.id }
] })
const messages = await waitForAssistant(invoke, session.id)
const user = messages.find(item => item.role === 'user')!
assert.deepEqual(user.userContent, layout, 'main process must preserve mixed order, deduplicate references, and reject forged view identities')
assert.equal(user.quotes?.[0].markdown, quote.markdown, 'the real table snapshot must survive independently of the UI label')
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as { sessions: Array<{ id: string; messages: ChatMessage[] }> }
assert.deepEqual(stored.sessions.find(item => item.id === session.id)?.messages.find(item => item.role === 'user')?.userContent, layout,
'inline presentation must reach the actual atomic session file')
return session.id
}
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 })
const project = await invoke('projects:create', { name: '指令项目', instructions: '项目回复必须包含验收标记 PROJECT-CONTEXT', experts: [], skills: [], connectors: [] }) as { projectId: string }
const projectTask = await invoke('session:create', '项目指令回归', { projectId: project.projectId }) as { id: string; projectId: string }
assert.equal(projectTask.projectId, project.projectId)
await invoke('chat:send', { sessionId: projectTask.id, text: '检查项目指令' })
const projectMessages = await waitForAssistant(invoke, projectTask.id)
const projectPrompt = state.timeline.find(item => item.kind === 'prompt' && item.sessionId === projectTask.id)?.text
assert.ok(projectPrompt?.includes('项目指令:\n项目回复必须包含验收标记 PROJECT-CONTEXT'), 'project instructions must reach the actual OMP adapter prompt')
assert.equal(projectMessages.find(item => item.role === 'user')?.content, '检查项目指令', 'project instructions must not overwrite the displayed user message')
await invoke('projects:update', project.projectId, { instructions: 'UPDATED-PROJECT-CONTEXT' })
await invoke('chat:send', { sessionId: projectTask.id, text: '检查更新指令' })
await waitForAssistant(invoke, projectTask.id)
const updatedProjectPrompt = state.timeline.filter(item => item.kind === 'prompt' && item.sessionId === projectTask.id).at(-1)?.text
assert.ok(updatedProjectPrompt?.includes('UPDATED-PROJECT-CONTEXT'), 'next project turn must use the updated persisted instructions')
await assert.rejects(invoke('session:update-context', projectTask.id, { projectId: 'another-project' }), /目标项目/)
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 testSenderRoutingAndPreflight(bundle, state, workspace)
await testInterruptedResend(bundle, state)
await testAttachments(bundle, state, tempRoot)
await testSpeech(bundle, state)
const inlineSessionId = await testInlineReferencePersistence(state, invoke)
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)
const restoredInline = await invoke('session:messages', inlineSessionId) as ChatMessage[]
assert.deepEqual(restoredInline.find(item => item.role === 'user')?.userContent,
[{ type: 'text', text: 'before' }, { type: 'quote', id: 'inline-q' }, { type: 'text', text: 'after' }],
'a fresh IPC/session repository must restore the same inline order after restart')
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
})