feat(conversation): replicate WorkBuddy rewind-to-message action (TODO 05)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 06:52:56 +08:00
1 parent 074523a501
commit c7ab047d52
11 files changed
+293 -60

No files matched your search

+71 -12
View File
@@ -22,6 +22,8 @@ if (!process.versions.electron) {
const sameYearDay = [2, 3, 4, 5].map((day) => new Date(currentYear, 0, day, 9, 5).getTime())
.find((value) => new Date(value).toDateString() !== todayKey)
const otherYear = new Date(currentYear - 2, 11, 31, 9, 5).getTime()
const crossYearUserId = crypto.randomUUID()
const sameYearUserId = crypto.randomUUID()
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
// 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。
@@ -30,21 +32,21 @@ if (!process.versions.electron) {
const editedFileAfter = '新内容:编辑后写入'
await fs.writeFile(editedFilePath, editedFileAfter, 'utf8')
const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
{ id: crypto.randomUUID(), role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done' },
{ id: crypto.randomUUID(), role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done' },
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done' },
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', tools: [
{ id: crossYearUserId, role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done', requestId: 'req-cross-year' },
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done', requestId: 'req-cross-year' },
{ id: sameYearUserId, role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done', requestId: 'req-same-year' },
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done', requestId: 'req-same-year' },
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done', requestId: 'req-first' },
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done', requestId: 'req-first' },
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done', requestId: 'req-second' },
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', requestId: 'req-second', tools: [
{ id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } }
] }
] }, { id: crypto.randomUUID(), title: 'Edit Other', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [
{ id: crypto.randomUUID(), role: 'user', content: '另一个会话', createdAt: now - 9000, status: 'done' }
] }]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter,
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ crossYearUserId, sameYearUserId, firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter,
sameYearDay, otherYear, userTimes: [otherYear, sameYearDay, now - 4000, now - 2000] }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
@@ -74,6 +76,7 @@ if (!process.versions.electron) {
let main, phase = 'boot'
const checks = [], snapshots = {}, rendererErrors = []
const sentPayloads = []
const revertCalls = []
let sendDelayMs = 0
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source) {
@@ -117,6 +120,13 @@ if (!process.versions.electron) {
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
await app.whenReady()
// 记录编辑提交负载并返回合成分支:隔离环境没有可用模型,但提交链路必须真实经过 IPC。
// 回退走真实渲染链路与真实 preload/IPC;主进程侧换成记录器,
// 真实文件与历史语义由 npm run test:conversation-ipc 覆盖。
ipcMain.removeHandler('chat:revert')
ipcMain.handle('chat:revert', async (_event, sessionId, messageId) => {
revertCalls.push({ sessionId, messageId })
return { messages: [] }
})
ipcMain.removeHandler('chat:send')
ipcMain.handle('chat:send', async (_event, payload) => {
if (sendDelayMs) await pause(sendDelayMs)
@@ -157,12 +167,13 @@ if (!process.versions.electron) {
check('toolbar is hidden while not hovered', idle.opacity === '0' && idle.pointerEvents === 'none', true)
check('toolbar time follows the 14/20 tertiary rule', idle.time.fontSize === '14px' && idle.time.lineHeight === '20px' && idle.time.color === 'rgba(0, 0, 0, 0.5)', true)
check('toolbar time uses HH:mm for today', /^\d{2}:\d{2}$/.test(idle.time.text), true)
// 该消息之后存在可回退的产物写入,因此原版会额外显示“恢复此版本”入口(ev_245ea174… 的无产物会话只有 copy + edit)。
// 原版桌面端回退入口标签来自 rewind.entry.tooltip(ConversationTimelineImpl.revertLabel),
// 该消息之后存在产物写入,因此弹窗走 artifacts 变体。
check('toolbar keeps the original button set (copy + revert + edit, no resend)', idle.buttons.length, 3)
check('button order is copy, revert, edit like the original', idle.buttons.map(item => item.action).join(','), 'copy,revert,edit')
check('buttons are 24x24 with 14x14 icons and radius 6', idle.buttons.every(item => item.width === 24 && item.height === 24 && item.radius === '6px' && item.icon[0] === 14 && item.icon[1] === 14), true)
check('copy/edit keep the 16x16 viewBox while revert keeps the recovered 14x14 one', idle.buttons.map(item => item.action + ':' + item.icon[2]).join(','), 'copy:0 0 16 16,revert:0 0 14 14,edit:0 0 16 16')
check('buttons expose the original aria labels', idle.buttons.map(item => item.label).join(','), '复制,恢复此版本,编辑')
check('buttons expose the recovered rewind tooltip', idle.buttons.map(item => item.label).join(','), '复制,将对话及产物回退到此消息前,编辑')
await step('toolbar-hover')
const bubble = idle.buttons[0] && (await read(`(()=>{const box=${lastFrame}.querySelector('.cr-self-bubble').getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`))
@@ -254,6 +265,9 @@ if (!process.versions.electron) {
snapshots.submitting = submitting
check('submitting disables cancel and send with aria-busy', submitting.cancel.disabled === true && submitting.send.disabled === true && submitting.send.busy === 'true', true)
check('submitting shows the spinner inside the send button', submitting.send.spinner, true)
// 原版 showDefaultRevert/showDefaultEdit = !isSessionActive:会话活动期间回退与编辑入口都不渲染。
const busyEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))})).filter(item => item.messageId)`)
check('active session hides rewind and edit entries like showDefaultRevert/showDefaultEdit', busyEntries.every(item => item.actions.join(',') === 'copy'), true)
for (let index = 0; index < 60 && sentPayloads.length === 0; index++) await pause(50)
const editPayload = sentPayloads.find(item => item.action === 'edit')
check('edit submit reaches chat:send through IPC', !!editPayload, true)
@@ -279,12 +293,57 @@ if (!process.versions.electron) {
check('Enter submits the edited message like the original onEnterSend', sentPayloads.filter(item => item.action === 'edit').length, 2)
check('second edit payload keeps the typed text', sentPayloads.filter(item => item.action === 'edit')[1].text, '回车提交')
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after enter submit')
// 编辑提交会以桩返回替换内存消息;重新选中会话以从真实 sessions 存储恢复夹具,
// 再验证回退入口(resolveRevertEntryVisible)、弹窗三态与确认链路。
await step('rewind-reload-fixture')
await read(`window.location.reload()`)
await wait(`!!document.querySelector('#root')`, 'renderer reloaded')
await pause(1500)
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows after reload')
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
await wait(`document.querySelectorAll('.cr-frame--right').length === 4`, 'fixture restored')
await step('rewind-entry')
const rewindEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))}))`)
snapshots.rewindEntries = rewindEntries
const entryOf = id => rewindEntries.find(item => item.messageId === id)?.actions ?? null
check('first user message hides the rewind entry like resolveRevertEntryVisible', entryOf(expected.crossYearUserId), ['copy', 'edit'])
check('later user messages render copy + rewind + edit', [expected.sameYearUserId, expected.firstUserId, expected.secondUserId].map(entryOf).map(actions => actions?.join(',')).join('|'),
'copy,revert,edit|copy,revert,edit|copy,revert,edit')
const openRewind = async () => {
await read(`(()=>{const frame=[...document.querySelectorAll('.cr-frame--right')].find(item => item.querySelector('.cr-toolbar')?.getAttribute('data-message-id') === ${JSON.stringify(expected.secondUserId)});const button=frame?.querySelector('.cr-toolbar .cr-icon-button[data-action="revert"]');if(!button)return false;button.click();return true})()`)
await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog')
}
await step('rewind-dialog-cancel')
await openRewind()
const dialog = await read(`(()=>{const dialog=document.querySelector('.dialog[role="dialog"]');if(!dialog)return null;const rows=[...dialog.querySelectorAll('.rewind-artifacts__row')].map(row=>({name:row.querySelector('.rewind-artifacts__name')?.textContent??null,title:row.querySelector('.rewind-artifacts__name')?.getAttribute('title')??null,status:row.querySelector('.rewind-artifacts__status')?.textContent??null}));const actions=[...dialog.querySelectorAll('.dialog__actions button')];return {title:dialog.querySelector('.dialog__title')?.textContent??null,description:dialog.querySelector('p')?.textContent??null,aria:dialog.getAttribute('aria-label'),labels:actions.map(button=>button.textContent),rows}})()`)
snapshots.rewindDialog = dialog
check('rewind dialog uses the recovered artifacts variant title', dialog.title, '将对话及以下产物版本回退到此消息前?')
check('rewind dialog carries the recovered description', dialog.description, '回退暂不支持部分类型文件')
check('rewind dialog actions are 取消/回退', dialog.labels.join(','), '取消,回退')
check('rewind dialog lists the pending artifact with its status', dialog.rows.length === 1 && dialog.rows[0].name === 'edited-artifact.txt' && dialog.rows[0].status === '回退至历史版本' && dialog.rows[0].title === expected.editedFilePath, true)
await shot('rewind-dialog')
await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`)
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed')
check('cancel keeps the conversation and never reaches chat:revert', revertCalls.length, 0)
await step('rewind-confirm')
await openRewind()
await read(`(()=>{const actions=[...document.querySelectorAll('.dialog[role="dialog"] .dialog__actions button')];actions[actions.length-1].click();return true})()`)
for (let index = 0; index < 60 && revertCalls.length === 0; index++) await pause(50)
check('confirm reaches chat:revert with the session and message id', revertCalls.length === 1 && revertCalls[0].messageId === expected.secondUserId && typeof revertCalls[0].sessionId === 'string', true)
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed after confirm')
check('rewind replaces the conversation with the rewound prefix', await read(`document.querySelectorAll('.cr-frame--right').length`), 0)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record payloads because the sandbox has no model.',
sourceEvidence: ['ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3', 'ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f',
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe'],
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe',
'ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90(rewind.* zh-CN 文案)',
'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb(ConversationTimelineImpl rewind 接线,脚本 SHA-256 5f35b748…)'],
snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('USER_MESSAGE_EDIT_' + report.result + ' ' + directory)