776 lines
43 KiB
TypeScript
776 lines
43 KiB
TypeScript
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',
|
||
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 () => '', 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: [] } }); }
|
||
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 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')
|
||
}
|
||
|
||
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; 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 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
|
||
})
|