feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user