feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+162 -6
View File
@@ -29,6 +29,9 @@ interface TestState {
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[]
}
@@ -69,7 +72,9 @@ async function waitForAssistant(
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
// 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[]
@@ -131,6 +136,23 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
{
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',
@@ -155,7 +177,7 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
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 shell = { openPath: async () => state.openPathError || '', openExternal: async () => {} };
export const powerSaveBlocker = {
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
stop: () => true,
@@ -179,6 +201,7 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
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) || [])] } });
@@ -241,10 +264,11 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
function register(bundle: TestBundle, state: TestState): (channel: string, ...args: unknown[]) => Promise<unknown> {
state.handlers.clear()
bundle.registerIpc(() => ({
const mainWindow = {
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send: (_channel: string, event: unknown) => state.events.push(event) }
}))
webContents: { isDestroyed: () => false, send: (channel: string, event: unknown) => { if (channel === 'chat:event') state.events.push(event) } }
}
bundle.registerIpc(() => mainWindow)
return invokeFor(state)
}
@@ -417,6 +441,91 @@ async function testEditArtifactRollback(bundle: TestBundle, state: TestState, wo
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> {
@@ -558,12 +667,13 @@ async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: s
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 }>
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')
@@ -572,6 +682,13 @@ async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: s
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')
}), /文件名无效/)
@@ -581,6 +698,8 @@ async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: s
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 }
@@ -713,6 +832,23 @@ async function testColdRecovery(tempRoot: string, state: TestState, bundle: Test
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 = {
@@ -749,18 +885,38 @@ async function main(): Promise<void> {
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)))